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,234 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
3
|
+
|
|
4
|
+
// ── Config ─────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const VENICE_API = 'https://api.venice.ai/api/v1';
|
|
7
|
+
const SIWE_DOMAIN = 'outerface.venice.ai';
|
|
8
|
+
const BASE_CHAIN_ID = 8453;
|
|
9
|
+
const VENICE_PAY_TO = '0x2670B922ef37C7Df47158725C0CC407b5382293F';
|
|
10
|
+
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
11
|
+
|
|
12
|
+
let walletKey: `0x${string}` | null = null;
|
|
13
|
+
let walletAddress: string | null = null;
|
|
14
|
+
|
|
15
|
+
export function initVeniceWallet(privateKey: string, address: string): void {
|
|
16
|
+
walletKey = privateKey as `0x${string}`;
|
|
17
|
+
walletAddress = address;
|
|
18
|
+
console.log(`[Venice x402] Wallet configured: ${address}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── SIWE Header ────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export async function buildSiweHeader(uri: string): Promise<string> {
|
|
24
|
+
if (!walletKey) throw new Error('Venice wallet not configured');
|
|
25
|
+
|
|
26
|
+
const account = privateKeyToAccount(walletKey);
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const expiry = new Date(now.getTime() + 10 * 60 * 1000);
|
|
29
|
+
const nonce = randomBytes(16).toString('hex');
|
|
30
|
+
|
|
31
|
+
const message = [
|
|
32
|
+
`${SIWE_DOMAIN} wants you to sign in with your Ethereum account:`,
|
|
33
|
+
account.address,
|
|
34
|
+
'',
|
|
35
|
+
'Sign in to Venice AI',
|
|
36
|
+
'',
|
|
37
|
+
`URI: ${uri}`,
|
|
38
|
+
`Version: 1`,
|
|
39
|
+
`Chain ID: ${BASE_CHAIN_ID}`,
|
|
40
|
+
`Nonce: ${nonce}`,
|
|
41
|
+
`Issued At: ${now.toISOString()}`,
|
|
42
|
+
`Expiration Time: ${expiry.toISOString()}`,
|
|
43
|
+
].join('\n');
|
|
44
|
+
|
|
45
|
+
const signature = await account.signMessage({ message });
|
|
46
|
+
|
|
47
|
+
return Buffer.from(JSON.stringify({
|
|
48
|
+
address: account.address.toLowerCase(),
|
|
49
|
+
message,
|
|
50
|
+
signature,
|
|
51
|
+
chainId: BASE_CHAIN_ID,
|
|
52
|
+
timestamp: now.getTime(),
|
|
53
|
+
}), 'utf-8').toString('base64');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Balance Check ──────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface VeniceBalance {
|
|
59
|
+
canConsume: boolean;
|
|
60
|
+
balanceUsd: number;
|
|
61
|
+
diemBalanceUsd: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function checkBalance(): Promise<VeniceBalance> {
|
|
65
|
+
if (!walletAddress) throw new Error('Venice wallet not configured');
|
|
66
|
+
|
|
67
|
+
const uri = `https://${SIWE_DOMAIN}/api/v1/x402/balance/${walletAddress}`;
|
|
68
|
+
const header = await buildSiweHeader(uri);
|
|
69
|
+
|
|
70
|
+
const res = await fetch(`${VENICE_API}/x402/balance/${walletAddress}`, {
|
|
71
|
+
headers: { 'X-Sign-In-With-X': header },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
throw new Error(`Venice balance check failed: ${res.status} ${text}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = await res.json() as any;
|
|
80
|
+
return {
|
|
81
|
+
canConsume: data.canConsume ?? false,
|
|
82
|
+
balanceUsd: parseFloat(data.balanceUsd ?? '0'),
|
|
83
|
+
diemBalanceUsd: parseFloat(data.diemBalanceUsd ?? '0'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Top-up via x402 ────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function topUp(amountUsd: number = 5): Promise<{ credited: number; newBalance: number } | null> {
|
|
90
|
+
if (!walletKey) throw new Error('Venice wallet not configured');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const account = privateKeyToAccount(walletKey);
|
|
94
|
+
const amountBaseUnits = BigInt(Math.round(amountUsd * 1e6));
|
|
95
|
+
const now = Math.floor(Date.now() / 1000);
|
|
96
|
+
const validAfter = BigInt(now - 60);
|
|
97
|
+
const validBefore = BigInt(now + 900);
|
|
98
|
+
const nonce = `0x${Array.from(crypto.getRandomValues(new Uint8Array(32))).map(b => b.toString(16).padStart(2, '0')).join('')}`;
|
|
99
|
+
|
|
100
|
+
// EIP-3009 transferWithAuthorization signature
|
|
101
|
+
const signature = await account.signTypedData({
|
|
102
|
+
domain: { name: 'USD Coin', version: '2', chainId: BASE_CHAIN_ID, verifyingContract: USDC_BASE as `0x${string}` },
|
|
103
|
+
types: {
|
|
104
|
+
TransferWithAuthorization: [
|
|
105
|
+
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' },
|
|
106
|
+
{ name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' },
|
|
107
|
+
{ name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
primaryType: 'TransferWithAuthorization',
|
|
111
|
+
message: {
|
|
112
|
+
from: account.address, to: VENICE_PAY_TO as `0x${string}`,
|
|
113
|
+
value: amountBaseUnits, validAfter, validBefore, nonce: nonce as `0x${string}`,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Build x402 payment header with 'accepted' field (Venice requires this)
|
|
118
|
+
const header = Buffer.from(JSON.stringify({
|
|
119
|
+
x402Version: 2,
|
|
120
|
+
scheme: 'exact',
|
|
121
|
+
network: `eip155:${BASE_CHAIN_ID}`,
|
|
122
|
+
accepted: {
|
|
123
|
+
scheme: 'exact',
|
|
124
|
+
network: `eip155:${BASE_CHAIN_ID}`,
|
|
125
|
+
payTo: VENICE_PAY_TO,
|
|
126
|
+
amount: amountBaseUnits.toString(),
|
|
127
|
+
asset: USDC_BASE,
|
|
128
|
+
extra: { name: 'USD Coin', version: '2' },
|
|
129
|
+
resource: `${VENICE_API}/x402/top-up`,
|
|
130
|
+
description: 'Venice x402 top-up',
|
|
131
|
+
mimeType: 'application/json',
|
|
132
|
+
maxTimeoutSeconds: 300,
|
|
133
|
+
},
|
|
134
|
+
payload: {
|
|
135
|
+
signature,
|
|
136
|
+
authorization: {
|
|
137
|
+
from: account.address, to: VENICE_PAY_TO,
|
|
138
|
+
value: amountBaseUnits.toString(),
|
|
139
|
+
validAfter: validAfter.toString(),
|
|
140
|
+
validBefore: validBefore.toString(),
|
|
141
|
+
nonce,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
})).toString('base64');
|
|
145
|
+
|
|
146
|
+
const res = await fetch(`${VENICE_API}/x402/top-up`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'X-402-Payment': header },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const text = await res.text();
|
|
153
|
+
throw new Error(`Top-up failed: ${res.status} ${text}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const data = await res.json() as any;
|
|
157
|
+
const credited = data.data?.amountCredited ?? amountUsd;
|
|
158
|
+
const newBal = data.data?.newBalance ?? 0;
|
|
159
|
+
console.log(`[Venice x402] Topped up $${credited} — new balance: $${newBal}`);
|
|
160
|
+
return { credited, newBalance: newBal };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`[Venice x402] Top-up failed: ${err}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Inference via wallet auth ──────────────────────────
|
|
168
|
+
|
|
169
|
+
export async function walletInference(
|
|
170
|
+
model: string,
|
|
171
|
+
systemPrompt: string,
|
|
172
|
+
userContent: string,
|
|
173
|
+
maxTokens: number = 200,
|
|
174
|
+
): Promise<string> {
|
|
175
|
+
const uri = `https://${SIWE_DOMAIN}/api/v1/chat/completions`;
|
|
176
|
+
const header = await buildSiweHeader(uri);
|
|
177
|
+
|
|
178
|
+
const res = await fetch(`${VENICE_API}/chat/completions`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'X-Sign-In-With-X': header,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
model,
|
|
186
|
+
messages: [
|
|
187
|
+
{ role: 'system', content: systemPrompt },
|
|
188
|
+
{ role: 'user', content: userContent },
|
|
189
|
+
],
|
|
190
|
+
max_tokens: maxTokens,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (res.status === 402) {
|
|
195
|
+
// Insufficient balance — try auto top-up
|
|
196
|
+
console.log('[Venice x402] Balance insufficient, attempting auto top-up...');
|
|
197
|
+
const result = await topUp(5);
|
|
198
|
+
if (!result) throw new Error('Auto top-up failed and Venice balance is insufficient');
|
|
199
|
+
|
|
200
|
+
// Retry inference after top-up
|
|
201
|
+
const retryHeader = await buildSiweHeader(uri);
|
|
202
|
+
const retryRes = await fetch(`${VENICE_API}/chat/completions`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: {
|
|
205
|
+
'Content-Type': 'application/json',
|
|
206
|
+
'X-Sign-In-With-X': retryHeader,
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
model,
|
|
210
|
+
messages: [
|
|
211
|
+
{ role: 'system', content: systemPrompt },
|
|
212
|
+
{ role: 'user', content: userContent },
|
|
213
|
+
],
|
|
214
|
+
max_tokens: maxTokens,
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (!retryRes.ok) {
|
|
219
|
+
const text = await retryRes.text();
|
|
220
|
+
throw new Error(`Venice inference failed after top-up: ${retryRes.status} ${text}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const retryData = await retryRes.json() as any;
|
|
224
|
+
return retryData.choices?.[0]?.message?.content ?? 'No summary generated.';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
throw new Error(`Venice inference failed: ${res.status} ${text}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const data = await res.json() as any;
|
|
233
|
+
return data.choices?.[0]?.message?.content ?? 'No summary generated.';
|
|
234
|
+
}
|
package/src/xmtp.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { DelegationRequest } from './governance.js';
|
|
2
|
+
|
|
3
|
+
// ── State ──────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
let xmtpReady = false;
|
|
6
|
+
let notifyAddress: string | null = null;
|
|
7
|
+
let xmtpClient: any = null;
|
|
8
|
+
let ownerAddress: string | null = null;
|
|
9
|
+
|
|
10
|
+
// ── Init ───────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export async function initXmtp(): Promise<string | null> {
|
|
13
|
+
try {
|
|
14
|
+
const { Client } = await import('@xmtp/node-sdk');
|
|
15
|
+
const { createWallet, getWallet, exportWallet } = await import('@open-wallet-standard/core');
|
|
16
|
+
const { mnemonicToAccount } = await import('viem/accounts');
|
|
17
|
+
const { toBytes } = await import('viem');
|
|
18
|
+
const { getRandomValues } = await import('node:crypto');
|
|
19
|
+
|
|
20
|
+
// Use a separate notify wallet — treasury keys never touch XMTP
|
|
21
|
+
const NOTIFY_WALLET = 'spendos-notify';
|
|
22
|
+
const OWS_VAULT = process.env.OWS_VAULT_PATH;
|
|
23
|
+
try { getWallet(NOTIFY_WALLET, OWS_VAULT); } catch { createWallet(NOTIFY_WALLET, undefined, undefined, OWS_VAULT); }
|
|
24
|
+
|
|
25
|
+
const mnemonic = exportWallet(NOTIFY_WALLET, undefined, OWS_VAULT);
|
|
26
|
+
const account = mnemonicToAccount(mnemonic);
|
|
27
|
+
notifyAddress = account.address;
|
|
28
|
+
|
|
29
|
+
// Get owner (treasury) address to send notifications TO
|
|
30
|
+
try {
|
|
31
|
+
const treasuryMnemonic = exportWallet('spendos-treasury', process.env.OWS_PASSPHRASE, OWS_VAULT);
|
|
32
|
+
const treasuryAccount = mnemonicToAccount(treasuryMnemonic);
|
|
33
|
+
ownerAddress = treasuryAccount.address;
|
|
34
|
+
} catch {
|
|
35
|
+
ownerAddress = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const signer = {
|
|
39
|
+
type: 'EOA' as const,
|
|
40
|
+
getIdentifier: () => ({ identifier: account.address, identifierKind: 0 }),
|
|
41
|
+
signMessage: async (message: string) => {
|
|
42
|
+
const sig = await account.signMessage({ message });
|
|
43
|
+
return toBytes(sig);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const dbEncryptionKey = getRandomValues(new Uint8Array(32));
|
|
48
|
+
xmtpClient = await Client.create(signer, { dbEncryptionKey });
|
|
49
|
+
xmtpReady = true;
|
|
50
|
+
console.log(`[XMTP] Client ready: ${notifyAddress} → notifications to ${ownerAddress ?? 'none'}`);
|
|
51
|
+
return notifyAddress;
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
console.log(`[XMTP] Init failed: ${err.message ?? err}`);
|
|
54
|
+
if (err.cause) console.log(`[XMTP] Cause: ${err.cause}`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Send XMTP Message ─────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function sendXmtpMessage(text: string): Promise<boolean> {
|
|
62
|
+
if (!xmtpReady || !xmtpClient || !ownerAddress) return false;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Create or get conversation with the owner wallet
|
|
66
|
+
const conversation = await xmtpClient.conversations.newDm(ownerAddress);
|
|
67
|
+
await conversation.send(text);
|
|
68
|
+
console.log(`[XMTP] Sent to ${ownerAddress}: ${text.slice(0, 80)}`);
|
|
69
|
+
return true;
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
console.log(`[XMTP] Send failed: ${err.message ?? err}`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Notifications ─────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function notifyDelegationRequest(d: DelegationRequest): Promise<boolean> {
|
|
79
|
+
const text = [
|
|
80
|
+
`🔔 SpendOS Delegation Request`,
|
|
81
|
+
``,
|
|
82
|
+
`Agent: ${d.agentAddress}`,
|
|
83
|
+
`Reason: ${d.reason}`,
|
|
84
|
+
`Chains: ${d.chains.join(', ')}`,
|
|
85
|
+
`Budget: $${d.totalBudget}`,
|
|
86
|
+
`Risk: ${d.aiInterpretation?.riskLevel?.toUpperCase() ?? 'unknown'}`,
|
|
87
|
+
``,
|
|
88
|
+
`Approve at https://spendos.xyz`,
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
const sent = await sendXmtpMessage(text);
|
|
92
|
+
if (!sent) console.log(`[XMTP/log] ${text.replace(/\n/g, ' | ')}`);
|
|
93
|
+
return sent;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function notifyDelegationDecision(
|
|
97
|
+
d: DelegationRequest,
|
|
98
|
+
action: 'approved' | 'rejected' | 'revoked' | 'expired',
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const emoji = { approved: '✅', rejected: '❌', revoked: '🔄', expired: '⏰' }[action];
|
|
101
|
+
const text = `${emoji} Delegation ${action.toUpperCase()}: ${d.reason}`;
|
|
102
|
+
const sent = await sendXmtpMessage(text);
|
|
103
|
+
if (!sent) console.log(`[XMTP/log] ${text}`);
|
|
104
|
+
return sent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getXmtpAddress(): string | null {
|
|
108
|
+
return notifyAddress;
|
|
109
|
+
}
|
package/src/zerion.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WalletPortfolio } from './governance.js';
|
|
2
|
+
|
|
3
|
+
const ZERION_API_KEY = process.env.ZERION_API_KEY ?? '';
|
|
4
|
+
const ZERION_BASE = 'https://api.zerion.io/v1';
|
|
5
|
+
|
|
6
|
+
export async function getWalletPortfolio(address: string): Promise<WalletPortfolio | null> {
|
|
7
|
+
if (!address) return null;
|
|
8
|
+
|
|
9
|
+
// Zerion API uses HTTP Basic Auth with API key as username
|
|
10
|
+
const authHeader = ZERION_API_KEY
|
|
11
|
+
? `Basic ${Buffer.from(`${ZERION_API_KEY}:`).toString('base64')}`
|
|
12
|
+
: '';
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${ZERION_BASE}/wallets/${address}/portfolio?currency=usd`, {
|
|
16
|
+
headers: authHeader ? {
|
|
17
|
+
'Authorization': authHeader,
|
|
18
|
+
'Accept': 'application/json',
|
|
19
|
+
} : { 'Accept': 'application/json' },
|
|
20
|
+
signal: AbortSignal.timeout(5000),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
console.log(`[Zerion] Portfolio fetch failed: ${res.status}`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = await res.json() as any;
|
|
29
|
+
const totalValue = data.data?.attributes?.total?.positions ?? 0;
|
|
30
|
+
|
|
31
|
+
// Get token positions
|
|
32
|
+
const tokensRes = await fetch(`${ZERION_BASE}/wallets/${address}/positions?filter[chain_ids]=base¤cy=usd`, {
|
|
33
|
+
headers: authHeader ? {
|
|
34
|
+
'Authorization': authHeader,
|
|
35
|
+
'Accept': 'application/json',
|
|
36
|
+
} : { 'Accept': 'application/json' },
|
|
37
|
+
signal: AbortSignal.timeout(5000),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const tokens: WalletPortfolio['tokens'] = [];
|
|
41
|
+
if (tokensRes.ok) {
|
|
42
|
+
const tokensData = await tokensRes.json() as any;
|
|
43
|
+
for (const pos of (tokensData.data ?? []).slice(0, 10)) {
|
|
44
|
+
const attrs = pos.attributes;
|
|
45
|
+
tokens.push({
|
|
46
|
+
symbol: attrs?.fungible_info?.symbol ?? '???',
|
|
47
|
+
balance: String(attrs?.quantity?.float ?? '0'),
|
|
48
|
+
valueUsd: attrs?.value ?? 0,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { totalValueUsd: totalValue, tokens };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(`[Zerion] Error: ${err}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/start.sh
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Railway sets PORT (usually 8080). Export before anything starts.
|
|
5
|
+
export PORT=${PORT:-8080}
|
|
6
|
+
export SPENDOS_INTERNAL_URL="http://localhost:${PORT}"
|
|
7
|
+
|
|
8
|
+
echo "[SpendOS] Starting on port $PORT..."
|
|
9
|
+
|
|
10
|
+
# Symlink memory + jobs to persistent volume so they survive redeploys
|
|
11
|
+
mkdir -p /data/memory
|
|
12
|
+
ln -sfn /data/memory /app/memory
|
|
13
|
+
|
|
14
|
+
# Persist agent-created jobs on volume
|
|
15
|
+
mkdir -p /data/jobs
|
|
16
|
+
# Copy seed jobs to volume (don't overwrite agent-created ones)
|
|
17
|
+
cp -n /app/jobs/*.json /data/jobs/ 2>/dev/null || true
|
|
18
|
+
# Symlink so runtime reads/writes to the volume
|
|
19
|
+
rm -rf /app/jobs
|
|
20
|
+
ln -sfn /data/jobs /app/jobs
|
|
21
|
+
|
|
22
|
+
# 1. Start SpendOS Express server
|
|
23
|
+
npx tsx src/server.ts &
|
|
24
|
+
SPENDOS_PID=$!
|
|
25
|
+
sleep 3
|
|
26
|
+
|
|
27
|
+
echo "[SpendOS] Express server started on :${PORT:-3030} (PID $SPENDOS_PID)"
|
|
28
|
+
|
|
29
|
+
# 2. Configure OpenClaw (only on first boot — check for marker file)
|
|
30
|
+
FIRST_BOOT_MARKER="$OPENCLAW_STATE_DIR/.spendos-configured"
|
|
31
|
+
if [ ! -f "$FIRST_BOOT_MARKER" ]; then
|
|
32
|
+
echo "[SpendOS] First boot — configuring OpenClaw..."
|
|
33
|
+
|
|
34
|
+
# Set Venice model via SpendOS proxy
|
|
35
|
+
openclaw config set agents.defaults.model.primary "spendos/kimi-k2-5" 2>/dev/null || true
|
|
36
|
+
openclaw config set models.providers.spendos '{"baseUrl":"http://localhost:3030/v1","apiKey":"spendos","api":"openai-completions","models":[{"id":"kimi-k2-5","name":"kimi-k2-5","contextWindow":256000}]}' 2>/dev/null || true
|
|
37
|
+
openclaw config set agents.defaults.models '{"spendos/kimi-k2-5":{"alias":"Venice via SpendOS"}}' 2>/dev/null || true
|
|
38
|
+
openclaw config set gateway.mode local 2>/dev/null || true
|
|
39
|
+
openclaw config set gateway.http.endpoints.chatCompletions.enabled true 2>/dev/null || true
|
|
40
|
+
|
|
41
|
+
# Set SpendOS MCP server (pre-compiled JS — no tsx needed)
|
|
42
|
+
openclaw mcp set spendos "{\"command\":\"node\",\"args\":[\"/app/dist/mcp-server.mjs\"],\"env\":{\"SPENDOS_URL\":\"http://localhost:${PORT:-3030}\"}}" 2>/dev/null || true
|
|
43
|
+
|
|
44
|
+
# Set MoonPay MCP (safe tools only)
|
|
45
|
+
openclaw mcp set moonpay '{"command":"npx","args":["@moonpay/cli","mcp"]}' 2>/dev/null || true
|
|
46
|
+
|
|
47
|
+
# Set Zerion MCP
|
|
48
|
+
openclaw mcp set zerion '{"url":"https://developers.zerion.io/mcp","transport":"streamable-http"}' 2>/dev/null || true
|
|
49
|
+
|
|
50
|
+
# Install SpendOS skill + plugin into OpenClaw
|
|
51
|
+
# Install skill
|
|
52
|
+
mkdir -p $OPENCLAW_STATE_DIR/skills/spendos
|
|
53
|
+
cp /app/skills/spendos/skill.md $OPENCLAW_STATE_DIR/skills/spendos/ 2>/dev/null || true
|
|
54
|
+
|
|
55
|
+
# Install plugin
|
|
56
|
+
mkdir -p $OPENCLAW_STATE_DIR/plugins
|
|
57
|
+
cp -r /app/plugins/spendos-events $OPENCLAW_STATE_DIR/plugins/ 2>/dev/null || true
|
|
58
|
+
cd $OPENCLAW_STATE_DIR/plugins/spendos-events && npm install --production 2>/dev/null || true
|
|
59
|
+
cd /app
|
|
60
|
+
|
|
61
|
+
# Register plugin with OpenClaw
|
|
62
|
+
openclaw config set plugins.spendos-events "{\"path\":\"$OPENCLAW_STATE_DIR/plugins/spendos-events\"}" 2>/dev/null || true
|
|
63
|
+
|
|
64
|
+
# Set workspace to /app so SOUL.md and AGENTS.md are loaded
|
|
65
|
+
openclaw config set agents.defaults.workspace /app 2>/dev/null || true
|
|
66
|
+
|
|
67
|
+
# Security: block agent from reading secrets
|
|
68
|
+
openclaw config set tools.exec.env.denylist '["OWS_PASSPHRASE","OWS_IMPORT_MNEMONIC","DEPLOYER_PRIVATE_KEY","SPENDOS_ADMIN_TOKEN","OPENCLAW_GATEWAY_TOKEN_OVERRIDE","TWITTER_USERNAME","TWITTER_PASSWORD"]' 2>/dev/null || true
|
|
69
|
+
|
|
70
|
+
# Enable browser tool (Chromium installed in Docker image)
|
|
71
|
+
openclaw config set tools.browser.enabled true 2>/dev/null || true
|
|
72
|
+
openclaw config set tools.browser.executable "$CHROME_BIN" 2>/dev/null || true
|
|
73
|
+
|
|
74
|
+
# Restore Twitter cookies from persistent volume if they exist
|
|
75
|
+
if [ -f "/data/twitter-cookies.json" ]; then
|
|
76
|
+
openclaw config set tools.browser.cookies "/data/twitter-cookies.json" 2>/dev/null || true
|
|
77
|
+
echo "[SpendOS] Twitter cookies restored from volume"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# Disable Slack channel (not needed, causes MODULE_NOT_FOUND errors)
|
|
81
|
+
openclaw config set channels.slack.enabled false 2>/dev/null || true
|
|
82
|
+
|
|
83
|
+
touch "$FIRST_BOOT_MARKER"
|
|
84
|
+
echo "[SpendOS] First boot configuration complete"
|
|
85
|
+
else
|
|
86
|
+
echo "[SpendOS] OpenClaw already configured (restarting with existing sessions/memory)"
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# ALWAYS update these on every boot (not just first boot)
|
|
90
|
+
if [ -n "$OPENCLAW_GATEWAY_TOKEN_OVERRIDE" ]; then
|
|
91
|
+
openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN_OVERRIDE" 2>/dev/null || true
|
|
92
|
+
fi
|
|
93
|
+
# Re-register SpendOS MCP server (compiled JS, no tsx dependency)
|
|
94
|
+
openclaw mcp set spendos "{\"command\":\"node\",\"args\":[\"/app/dist/mcp-server.mjs\"],\"env\":{\"SPENDOS_URL\":\"http://localhost:${PORT:-3030}\"}}" 2>/dev/null || true
|
|
95
|
+
|
|
96
|
+
# 3. Set up cron jobs (if not already set)
|
|
97
|
+
# Get OpenClaw gateway token for internal API calls
|
|
98
|
+
# openclaw config get redacts the token, so read directly from the JSON config
|
|
99
|
+
export OPENCLAW_GATEWAY_TOKEN=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('$OPENCLAW_STATE_DIR/openclaw.json','utf8'));process.stdout.write(c.gateway?.auth?.token||'')}catch{}" 2>/dev/null || echo "")
|
|
100
|
+
export OPENCLAW_INTERNAL_URL="http://localhost:18789"
|
|
101
|
+
|
|
102
|
+
# OpenClaw gateway on INTERNAL port only — NOT exposed to internet
|
|
103
|
+
openclaw gateway --port 18789 &
|
|
104
|
+
GATEWAY_PID=$!
|
|
105
|
+
sleep 5
|
|
106
|
+
|
|
107
|
+
echo "[SpendOS] OpenClaw gateway started on :18789 INTERNAL (PID $GATEWAY_PID)"
|
|
108
|
+
|
|
109
|
+
# Add cron jobs only on first boot
|
|
110
|
+
# COST AWARENESS: each cron invocation costs ~$0.05 in Venice inference.
|
|
111
|
+
# Only invoke the agent when there's revenue to justify it.
|
|
112
|
+
# The venice-topup cron is removed — auto top-up is now built into the proxy.
|
|
113
|
+
# Reset crons on every boot to pick up config changes
|
|
114
|
+
openclaw cron remove --name "revenue-scanner" 2>/dev/null || true
|
|
115
|
+
openclaw cron remove --name "venice-topup" 2>/dev/null || true
|
|
116
|
+
if true; then
|
|
117
|
+
openclaw cron add \
|
|
118
|
+
--name "revenue-scanner" \
|
|
119
|
+
--every "2h" \
|
|
120
|
+
--agent main \
|
|
121
|
+
--session isolated \
|
|
122
|
+
--timeout-seconds 90 \
|
|
123
|
+
--light-context \
|
|
124
|
+
--message "You are SpendOS. COST RULE: This cron costs ~\$0.05 per run. Only take actions that generate more than \$0.05 in expected value.
|
|
125
|
+
|
|
126
|
+
Quick check: curl -s http://localhost:3030/api/pnl
|
|
127
|
+
|
|
128
|
+
If earned > 0 and margin is improving, propose ONE yield delegation.
|
|
129
|
+
If no revenue, create ONE new job (write JSON to /app/jobs/ then curl -X POST http://localhost:3030/api/jobs/reload).
|
|
130
|
+
If you already created jobs recently, just report status — don't burn credits on busywork.
|
|
131
|
+
Keep responses SHORT. Every token costs money." \
|
|
132
|
+
--description "Cost-aware revenue scanner (2h)" \
|
|
133
|
+
2>/dev/null || echo "[SpendOS] Cron already exists"
|
|
134
|
+
|
|
135
|
+
touch "$FIRST_BOOT_MARKER.crons"
|
|
136
|
+
echo "[SpendOS] Cron jobs configured"
|
|
137
|
+
else
|
|
138
|
+
echo "[SpendOS] Cron jobs already exist"
|
|
139
|
+
fi
|
|
140
|
+
# 4. Start ACP seller runtime (agent-to-agent commerce marketplace)
|
|
141
|
+
# Decode ACP config from base64 env var (injected by setup.sh --deploy)
|
|
142
|
+
if [ -n "$ACP_CONFIG_B64" ] && [ -d "/app/acp-seller" ]; then
|
|
143
|
+
echo "$ACP_CONFIG_B64" | base64 -d > /app/acp-seller/config.json
|
|
144
|
+
LITE_AGENT_API_KEY=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('/app/acp-seller/config.json'));process.stdout.write(c.LITE_AGENT_API_KEY||'')}catch{}" 2>/dev/null)
|
|
145
|
+
export LITE_AGENT_API_KEY
|
|
146
|
+
echo "[SpendOS] ACP config decoded from ACP_CONFIG_B64"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
if [ -n "$LITE_AGENT_API_KEY" ] && [ -d "/app/acp-seller" ]; then
|
|
150
|
+
echo "[SpendOS] Starting ACP seller runtime..."
|
|
151
|
+
cd /app/acp-seller
|
|
152
|
+
SPENDOS_URL="http://localhost:${PORT:-8080}" GUARDIAN_INTERNAL_API_TOKEN="${GUARDIAN_INTERNAL_API_TOKEN:-skip}" \
|
|
153
|
+
npx tsx bin/acp.ts serve start 2>&1 || echo "[SpendOS] ACP seller failed to start"
|
|
154
|
+
cd /app
|
|
155
|
+
echo "[SpendOS] ACP seller runtime started (accepting jobs from other agents)"
|
|
156
|
+
else
|
|
157
|
+
echo "[SpendOS] ACP seller skipped (set LITE_AGENT_API_KEY to enable)"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
echo "[SpendOS] System ready:"
|
|
161
|
+
echo " Dashboard (PUBLIC): http://localhost:${PORT:-8080}"
|
|
162
|
+
echo " OpenClaw (INTERNAL): http://localhost:18789 (not exposed to internet)"
|
|
163
|
+
if [ -n "$LITE_AGENT_API_KEY" ]; then
|
|
164
|
+
echo " ACP Seller: connected to acpx.virtuals.io (3 offerings)"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
# Wait for either process to exit
|
|
168
|
+
wait $SPENDOS_PID $GATEWAY_PID
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"resolveJsonModule": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|