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,363 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Payment proof verification for seller runtime.
|
|
3
|
+
//
|
|
4
|
+
// Before executing a job in the TRANSACTION phase, the seller must verify
|
|
5
|
+
// that a payment proof memo (PAYABLE_TRANSFER or TXHASH) exists and has
|
|
6
|
+
// non-empty content. This prevents execution without payment confirmation.
|
|
7
|
+
//
|
|
8
|
+
// Phase 1 (format check): verifyPaymentProof() — validates memo exists with
|
|
9
|
+
// a well-formed tx hash.
|
|
10
|
+
// Phase 2 (on-chain check): verifyPaymentOnChain() — confirms the tx exists
|
|
11
|
+
// on-chain, succeeded, transferred the correct USDC amount to the correct
|
|
12
|
+
// recipient, and has not been replayed.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
import { MemoType, type AcpMemoData } from "./types.js";
|
|
16
|
+
import { createPublicClient, http, type PublicClient, type Chain } from "viem";
|
|
17
|
+
import { base } from "viem/chains";
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
export interface PaymentVerificationResult {
|
|
24
|
+
verified: boolean;
|
|
25
|
+
memo?: AcpMemoData;
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OnChainVerificationResult {
|
|
30
|
+
verified: boolean;
|
|
31
|
+
txHash: `0x${string}`;
|
|
32
|
+
blockNumber?: bigint;
|
|
33
|
+
from?: string;
|
|
34
|
+
to?: string;
|
|
35
|
+
amount?: bigint;
|
|
36
|
+
reason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Constants
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Regex that a valid EVM transaction hash must satisfy:
|
|
45
|
+
* - 0x prefix
|
|
46
|
+
* - exactly 64 lowercase or uppercase hex characters
|
|
47
|
+
*/
|
|
48
|
+
const TX_HASH_REGEX = /^0x[a-fA-F0-9]{64}$/;
|
|
49
|
+
|
|
50
|
+
function getBaseUsdcAddress(): string | null {
|
|
51
|
+
const raw = process.env.BASE_USDC_ADDRESS?.trim();
|
|
52
|
+
if (!raw) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(raw)) {
|
|
56
|
+
console.error(
|
|
57
|
+
`[paymentVerification] BASE_USDC_ADDRESS is invalid (expected 0x-prefixed 40-char address): ${raw}`,
|
|
58
|
+
);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return raw.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Replay protection — in-memory seen-tx-hash set
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
const seenTxHashes = new Set<string>();
|
|
69
|
+
const MAX_SEEN_TX_HASHES = 10_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns true if this tx hash has already been successfully verified.
|
|
73
|
+
* Purely a read — does not mutate the set.
|
|
74
|
+
*/
|
|
75
|
+
function isTxHashSeen(txHash: string): boolean {
|
|
76
|
+
return seenTxHashes.has(txHash.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Record a tx hash as successfully verified.
|
|
81
|
+
* Evicts the oldest entries when the set exceeds the cap.
|
|
82
|
+
*/
|
|
83
|
+
function markTxHashSeen(txHash: string): void {
|
|
84
|
+
const normalized = txHash.toLowerCase();
|
|
85
|
+
if (seenTxHashes.has(normalized)) return;
|
|
86
|
+
|
|
87
|
+
// Evict oldest 10% when at capacity.
|
|
88
|
+
// Set iteration order is insertion order, so the first entries are oldest.
|
|
89
|
+
if (seenTxHashes.size >= MAX_SEEN_TX_HASHES) {
|
|
90
|
+
const evictCount = Math.max(1, Math.floor(MAX_SEEN_TX_HASHES * 0.1));
|
|
91
|
+
const iter = seenTxHashes.values();
|
|
92
|
+
for (let i = 0; i < evictCount; i++) {
|
|
93
|
+
const next = iter.next();
|
|
94
|
+
if (next.done) break;
|
|
95
|
+
seenTxHashes.delete(next.value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
seenTxHashes.add(normalized);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// RPC client — lazily created singleton
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
let _publicClient: PublicClient | null = null;
|
|
107
|
+
|
|
108
|
+
function getBaseRpcUrl(): string {
|
|
109
|
+
return (
|
|
110
|
+
process.env.BASE_RPC_URL ||
|
|
111
|
+
process.env.ALCHEMY_BASE_HTTP ||
|
|
112
|
+
"https://mainnet.base.org"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getPublicClient(): PublicClient {
|
|
117
|
+
if (!_publicClient) {
|
|
118
|
+
const rpcUrl = getBaseRpcUrl();
|
|
119
|
+
console.log(
|
|
120
|
+
`[paymentVerification] Creating Base public client (rpc=${rpcUrl.replace(/\/[^/]*api[^/]*$/i, "/***")})`,
|
|
121
|
+
);
|
|
122
|
+
_publicClient = createPublicClient({
|
|
123
|
+
chain: base as Chain,
|
|
124
|
+
transport: http(rpcUrl),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return _publicClient;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// Phase 1: Format-level payment proof check (unchanged)
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Verify that a job's memos contain valid payment proof.
|
|
136
|
+
*
|
|
137
|
+
* Returns `{ verified: true, memo }` if a PAYABLE_TRANSFER or TXHASH
|
|
138
|
+
* memo is found whose content is a properly formatted EVM transaction
|
|
139
|
+
* hash (`0x` followed by 64 hex characters). Otherwise returns
|
|
140
|
+
* `{ verified: false, reason }` explaining why verification failed.
|
|
141
|
+
*/
|
|
142
|
+
export function verifyPaymentProof(
|
|
143
|
+
memos: AcpMemoData[],
|
|
144
|
+
): PaymentVerificationResult {
|
|
145
|
+
const paymentMemo = memos.find(
|
|
146
|
+
(m) =>
|
|
147
|
+
m.memoType === MemoType.PAYABLE_TRANSFER ||
|
|
148
|
+
m.memoType === MemoType.TXHASH,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (!paymentMemo) {
|
|
152
|
+
return {
|
|
153
|
+
verified: false,
|
|
154
|
+
reason:
|
|
155
|
+
"No payment proof memo found. Expected PAYABLE_TRANSFER or TXHASH memo.",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const content = paymentMemo.content?.trim();
|
|
160
|
+
if (!content) {
|
|
161
|
+
return {
|
|
162
|
+
verified: false,
|
|
163
|
+
reason: `Payment memo (type=${MemoType[paymentMemo.memoType]}) has empty content.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!TX_HASH_REGEX.test(content)) {
|
|
168
|
+
return {
|
|
169
|
+
verified: false,
|
|
170
|
+
reason: `Payment memo content is not a valid transaction hash. Expected 0x-prefixed 64-character hex string, got: "${content}".`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { verified: true, memo: paymentMemo };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// =============================================================================
|
|
178
|
+
// Phase 2: On-chain payment verification
|
|
179
|
+
// =============================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Verify that a transaction hash corresponds to a real, successful USDC
|
|
183
|
+
* transfer on Base mainnet that matches the expected recipient and amount.
|
|
184
|
+
*
|
|
185
|
+
* Steps:
|
|
186
|
+
* 1. Replay check — reject previously-seen tx hashes.
|
|
187
|
+
* 2. Fetch tx receipt — confirm tx exists and succeeded.
|
|
188
|
+
* 3. Parse Transfer logs — find USDC transfers in the receipt.
|
|
189
|
+
* 4. Amount check — transfer value >= expectedMinAmount.
|
|
190
|
+
* 5. Recipient check — transfer `to` matches expectedRecipient.
|
|
191
|
+
* 6. Record tx hash in seen-set for future replay protection.
|
|
192
|
+
*/
|
|
193
|
+
export async function verifyPaymentOnChain(
|
|
194
|
+
txHash: `0x${string}`,
|
|
195
|
+
options: {
|
|
196
|
+
expectedRecipient?: string;
|
|
197
|
+
expectedMinAmount?: bigint;
|
|
198
|
+
chainId?: number;
|
|
199
|
+
} = {},
|
|
200
|
+
): Promise<OnChainVerificationResult> {
|
|
201
|
+
const tag = `[paymentVerification] tx=${txHash.slice(0, 10)}...`;
|
|
202
|
+
const baseUsdcAddress = getBaseUsdcAddress();
|
|
203
|
+
if (!baseUsdcAddress) {
|
|
204
|
+
return {
|
|
205
|
+
verified: false,
|
|
206
|
+
txHash,
|
|
207
|
+
reason: "BASE_USDC_ADDRESS is not configured",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── 1. Replay check ──────────────────────────────────────────────────
|
|
212
|
+
if (isTxHashSeen(txHash)) {
|
|
213
|
+
console.log(`${tag} REJECTED — replay detected (tx hash already used)`);
|
|
214
|
+
return {
|
|
215
|
+
verified: false,
|
|
216
|
+
txHash,
|
|
217
|
+
reason: "Transaction hash already used (replay detected)",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── 2. Fetch transaction receipt ─────────────────────────────────────
|
|
222
|
+
const client = getPublicClient();
|
|
223
|
+
let receipt: Awaited<ReturnType<PublicClient["getTransactionReceipt"]>>;
|
|
224
|
+
|
|
225
|
+
console.log(`${tag} Fetching transaction receipt from Base...`);
|
|
226
|
+
try {
|
|
227
|
+
receipt = await client.getTransactionReceipt({ hash: txHash });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
230
|
+
console.error(`${tag} Failed to fetch receipt: ${msg}`);
|
|
231
|
+
return {
|
|
232
|
+
verified: false,
|
|
233
|
+
txHash,
|
|
234
|
+
reason: `Failed to fetch transaction receipt: ${msg}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── 3. Verify tx succeeded ───────────────────────────────────────────
|
|
239
|
+
if (receipt.status !== "success") {
|
|
240
|
+
console.log(
|
|
241
|
+
`${tag} REJECTED — tx status="${receipt.status}" (expected "success")`,
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
verified: false,
|
|
245
|
+
txHash,
|
|
246
|
+
blockNumber: receipt.blockNumber,
|
|
247
|
+
reason: `Transaction did not succeed (status="${receipt.status}")`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(
|
|
252
|
+
`${tag} Receipt OK — block=${receipt.blockNumber}, status=success, logs=${receipt.logs.length}`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// ── 4. Parse USDC Transfer logs ──────────────────────────────────────
|
|
256
|
+
// The Transfer event topic0:
|
|
257
|
+
// keccak256("Transfer(address,address,uint256)")
|
|
258
|
+
// = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
|
|
259
|
+
const TRANSFER_TOPIC0 =
|
|
260
|
+
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
261
|
+
|
|
262
|
+
const usdcTransfers = receipt.logs.filter(
|
|
263
|
+
(log) =>
|
|
264
|
+
log.address.toLowerCase() === baseUsdcAddress &&
|
|
265
|
+
log.topics[0] === TRANSFER_TOPIC0 &&
|
|
266
|
+
log.topics.length >= 3,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (usdcTransfers.length === 0) {
|
|
270
|
+
console.log(`${tag} REJECTED — no USDC Transfer events found in receipt`);
|
|
271
|
+
return {
|
|
272
|
+
verified: false,
|
|
273
|
+
txHash,
|
|
274
|
+
blockNumber: receipt.blockNumber,
|
|
275
|
+
reason: "No USDC transfer events found in transaction",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 5. Find a matching transfer ──────────────────────────────────────
|
|
280
|
+
// Decode each USDC Transfer log and look for one that satisfies both
|
|
281
|
+
// the recipient and amount constraints.
|
|
282
|
+
for (const log of usdcTransfers) {
|
|
283
|
+
// topics[1] = from (address, left-padded to 32 bytes)
|
|
284
|
+
// topics[2] = to (address, left-padded to 32 bytes)
|
|
285
|
+
// data = value (uint256)
|
|
286
|
+
const from = ("0x" + (log.topics[1]?.slice(26) ?? "")).toLowerCase();
|
|
287
|
+
const to = ("0x" + (log.topics[2]?.slice(26) ?? "")).toLowerCase();
|
|
288
|
+
const value = BigInt(log.data);
|
|
289
|
+
|
|
290
|
+
// Check recipient
|
|
291
|
+
if (
|
|
292
|
+
options.expectedRecipient &&
|
|
293
|
+
to !== options.expectedRecipient.toLowerCase()
|
|
294
|
+
) {
|
|
295
|
+
continue; // wrong recipient — try next transfer log
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check amount
|
|
299
|
+
if (
|
|
300
|
+
options.expectedMinAmount !== undefined &&
|
|
301
|
+
value < options.expectedMinAmount
|
|
302
|
+
) {
|
|
303
|
+
console.log(
|
|
304
|
+
`${tag} Transfer to=${to} amount=${value} below minimum=${options.expectedMinAmount} — skipping`,
|
|
305
|
+
);
|
|
306
|
+
continue; // insufficient amount — try next transfer log
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── 6. Success — record and return ───────────────────────────────
|
|
310
|
+
markTxHashSeen(txHash);
|
|
311
|
+
console.log(
|
|
312
|
+
`${tag} VERIFIED — from=${from}, to=${to}, amount=${value}, block=${receipt.blockNumber}`,
|
|
313
|
+
);
|
|
314
|
+
return {
|
|
315
|
+
verified: true,
|
|
316
|
+
txHash,
|
|
317
|
+
blockNumber: receipt.blockNumber,
|
|
318
|
+
from,
|
|
319
|
+
to,
|
|
320
|
+
amount: value,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// No transfer matched all constraints
|
|
325
|
+
const transferSummary = usdcTransfers
|
|
326
|
+
.map((log) => {
|
|
327
|
+
const to = "0x" + (log.topics[2]?.slice(26) ?? "");
|
|
328
|
+
const value = BigInt(log.data);
|
|
329
|
+
return `to=${to} amount=${value}`;
|
|
330
|
+
})
|
|
331
|
+
.join("; ");
|
|
332
|
+
|
|
333
|
+
const reasons: string[] = [];
|
|
334
|
+
if (options.expectedRecipient) {
|
|
335
|
+
reasons.push(`expectedRecipient=${options.expectedRecipient}`);
|
|
336
|
+
}
|
|
337
|
+
if (options.expectedMinAmount !== undefined) {
|
|
338
|
+
reasons.push(`expectedMinAmount=${options.expectedMinAmount}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(
|
|
342
|
+
`${tag} REJECTED — no USDC transfer matched constraints (${reasons.join(", ")}). Found: ${transferSummary}`,
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
verified: false,
|
|
346
|
+
txHash,
|
|
347
|
+
blockNumber: receipt.blockNumber,
|
|
348
|
+
reason: `No USDC transfer in tx matched constraints (${reasons.join(", ")}). Transfers found: ${transferSummary}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// =============================================================================
|
|
353
|
+
// Test helpers — only for unit tests
|
|
354
|
+
// =============================================================================
|
|
355
|
+
|
|
356
|
+
export const __testing = {
|
|
357
|
+
seenTxHashes,
|
|
358
|
+
resetSeenTxHashes: () => seenTxHashes.clear(),
|
|
359
|
+
resetPublicClient: () => {
|
|
360
|
+
_publicClient = null;
|
|
361
|
+
},
|
|
362
|
+
MAX_SEEN_TX_HASHES,
|
|
363
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
AcpJobPhase,
|
|
4
|
+
MemoType,
|
|
5
|
+
type AcpJobEventData,
|
|
6
|
+
type AcpMemoData,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { deliverJob } from "./sellerApi.js";
|
|
9
|
+
|
|
10
|
+
const mockExecuteJob = vi.fn().mockResolvedValue({
|
|
11
|
+
deliverable: { type: "guardian_scan_result", value: { success: true } },
|
|
12
|
+
});
|
|
13
|
+
const mockValidateReqs = vi.fn().mockReturnValue({ valid: true });
|
|
14
|
+
const mockVerifyPaymentProof = vi.fn();
|
|
15
|
+
const mockVerifyPaymentOnChain = vi.fn();
|
|
16
|
+
|
|
17
|
+
vi.mock("./sellerApi.js", () => ({
|
|
18
|
+
acceptOrRejectJob: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
requestPayment: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
deliverJob: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("./offerings.js", () => ({
|
|
24
|
+
loadOffering: vi.fn().mockResolvedValue({
|
|
25
|
+
config: {
|
|
26
|
+
name: "x402janus_forensic_intelligence",
|
|
27
|
+
description: "test",
|
|
28
|
+
jobFee: 1,
|
|
29
|
+
jobFeeType: "fixed",
|
|
30
|
+
requiredFunds: false,
|
|
31
|
+
},
|
|
32
|
+
handlers: {
|
|
33
|
+
executeJob: (...args: unknown[]) => mockExecuteJob(...args),
|
|
34
|
+
validateRequirements: (...args: unknown[]) => mockValidateReqs(...args),
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
listOfferings: vi.fn().mockReturnValue([]),
|
|
38
|
+
logOfferingsStatus: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock("../../lib/wallet.js", () => ({
|
|
42
|
+
getMyAgentInfo: vi.fn().mockResolvedValue({
|
|
43
|
+
walletAddress: "0xSELLER",
|
|
44
|
+
name: "test-agent",
|
|
45
|
+
}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("../../lib/config.js", () => ({
|
|
49
|
+
checkForExistingProcess: vi.fn(),
|
|
50
|
+
writePidToConfig: vi.fn(),
|
|
51
|
+
removePidFromConfig: vi.fn(),
|
|
52
|
+
sanitizeAgentName: vi.fn((name: string) => name),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("./paymentVerification.js", () => ({
|
|
56
|
+
verifyPaymentProof: (...args: unknown[]) => mockVerifyPaymentProof(...args),
|
|
57
|
+
verifyPaymentOnChain: (...args: unknown[]) =>
|
|
58
|
+
mockVerifyPaymentOnChain(...args),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const VALID_WALLET = "0x1234567890abcdef1234567890abcdef12345678";
|
|
62
|
+
const SELLER_ADDR = "0xseller";
|
|
63
|
+
|
|
64
|
+
function makeNegotiationMemo(
|
|
65
|
+
content: Record<string, unknown>,
|
|
66
|
+
id = 100,
|
|
67
|
+
): AcpMemoData {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
memoType: MemoType.MESSAGE,
|
|
71
|
+
content: JSON.stringify(content),
|
|
72
|
+
nextPhase: AcpJobPhase.NEGOTIATION,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makePaymentMemo(
|
|
77
|
+
txHash = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
78
|
+
): AcpMemoData {
|
|
79
|
+
return {
|
|
80
|
+
id: 200,
|
|
81
|
+
memoType: MemoType.PAYABLE_TRANSFER,
|
|
82
|
+
content: txHash,
|
|
83
|
+
nextPhase: AcpJobPhase.TRANSACTION,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeJobEvent(
|
|
88
|
+
overrides: Partial<AcpJobEventData> = {},
|
|
89
|
+
): AcpJobEventData {
|
|
90
|
+
return {
|
|
91
|
+
id: 1,
|
|
92
|
+
phase: AcpJobPhase.TRANSACTION,
|
|
93
|
+
clientAddress: "0xclient",
|
|
94
|
+
providerAddress: SELLER_ADDR,
|
|
95
|
+
evaluatorAddress: "0xeval",
|
|
96
|
+
price: 0.05,
|
|
97
|
+
memos: [],
|
|
98
|
+
context: {},
|
|
99
|
+
...overrides,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("seller runtime — on-chain payment verification", () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.resetModules();
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
process.env.ACP_VERIFY_PAYMENT_ONCHAIN = "true";
|
|
108
|
+
|
|
109
|
+
mockVerifyPaymentProof.mockReturnValue({
|
|
110
|
+
verified: true,
|
|
111
|
+
memo: makePaymentMemo(),
|
|
112
|
+
});
|
|
113
|
+
mockValidateReqs.mockReturnValue({ valid: true });
|
|
114
|
+
mockExecuteJob.mockResolvedValue({
|
|
115
|
+
deliverable: {
|
|
116
|
+
type: "guardian_scan_result",
|
|
117
|
+
value: { success: true, wallet: VALID_WALLET },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("delivers an error and refuses execution when on-chain verification returns verified=false", async () => {
|
|
123
|
+
mockVerifyPaymentOnChain.mockResolvedValue({
|
|
124
|
+
verified: false,
|
|
125
|
+
txHash:
|
|
126
|
+
"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
127
|
+
reason: "Transaction hash already used (replay detected)",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const { __testing } = await import("./seller.js");
|
|
131
|
+
const {
|
|
132
|
+
handleNewTask,
|
|
133
|
+
resetSeenEvents,
|
|
134
|
+
resetInFlightJobs,
|
|
135
|
+
setSellerWallet,
|
|
136
|
+
setAgentDirName,
|
|
137
|
+
setAvailableOfferings,
|
|
138
|
+
} = __testing;
|
|
139
|
+
|
|
140
|
+
resetSeenEvents();
|
|
141
|
+
resetInFlightJobs();
|
|
142
|
+
setSellerWallet(SELLER_ADDR);
|
|
143
|
+
setAgentDirName("x402janus");
|
|
144
|
+
setAvailableOfferings(["x402janus_forensic_intelligence"]);
|
|
145
|
+
|
|
146
|
+
const memo = makeNegotiationMemo({
|
|
147
|
+
name: "x402janus_forensic_intelligence",
|
|
148
|
+
requirement: { walletAddress: VALID_WALLET },
|
|
149
|
+
});
|
|
150
|
+
const data = makeJobEvent({
|
|
151
|
+
id: 301,
|
|
152
|
+
memos: [memo, makePaymentMemo()],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await handleNewTask(data);
|
|
156
|
+
|
|
157
|
+
expect(mockExecuteJob).not.toHaveBeenCalled();
|
|
158
|
+
expect(deliverJob).toHaveBeenCalledWith(
|
|
159
|
+
301,
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
deliverable: expect.objectContaining({
|
|
162
|
+
type: "guardian_scan_error",
|
|
163
|
+
value: expect.objectContaining({
|
|
164
|
+
success: false,
|
|
165
|
+
error: "On-chain payment verification failed",
|
|
166
|
+
reason: "Transaction hash already used (replay detected)",
|
|
167
|
+
jobId: 301,
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("fails closed when on-chain verification throws (no execution fallback)", async () => {
|
|
175
|
+
mockVerifyPaymentOnChain.mockRejectedValue(new Error("rpc timeout"));
|
|
176
|
+
|
|
177
|
+
const { __testing } = await import("./seller.js");
|
|
178
|
+
const {
|
|
179
|
+
handleNewTask,
|
|
180
|
+
resetSeenEvents,
|
|
181
|
+
resetInFlightJobs,
|
|
182
|
+
setSellerWallet,
|
|
183
|
+
setAgentDirName,
|
|
184
|
+
setAvailableOfferings,
|
|
185
|
+
} = __testing;
|
|
186
|
+
|
|
187
|
+
resetSeenEvents();
|
|
188
|
+
resetInFlightJobs();
|
|
189
|
+
setSellerWallet(SELLER_ADDR);
|
|
190
|
+
setAgentDirName("x402janus");
|
|
191
|
+
setAvailableOfferings(["x402janus_forensic_intelligence"]);
|
|
192
|
+
|
|
193
|
+
const memo = makeNegotiationMemo({
|
|
194
|
+
name: "x402janus_forensic_intelligence",
|
|
195
|
+
requirement: { walletAddress: VALID_WALLET },
|
|
196
|
+
});
|
|
197
|
+
const data = makeJobEvent({
|
|
198
|
+
id: 302,
|
|
199
|
+
memos: [memo, makePaymentMemo()],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await handleNewTask(data);
|
|
203
|
+
|
|
204
|
+
expect(mockExecuteJob).not.toHaveBeenCalled();
|
|
205
|
+
expect(deliverJob).toHaveBeenCalledWith(
|
|
206
|
+
302,
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
deliverable: expect.objectContaining({
|
|
209
|
+
type: "guardian_scan_error",
|
|
210
|
+
value: expect.objectContaining({
|
|
211
|
+
success: false,
|
|
212
|
+
error: "On-chain payment verification failed",
|
|
213
|
+
reason: expect.stringContaining("rpc timeout"),
|
|
214
|
+
jobId: 302,
|
|
215
|
+
}),
|
|
216
|
+
}),
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|