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,277 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Dynamic loader for seller offerings.
|
|
3
|
+
// Offerings are stored per-agent: src/seller/offerings/<agent-name>/<offering>/
|
|
4
|
+
// =============================================================================
|
|
5
|
+
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import type { OfferingHandlers } from "./offeringTypes.js";
|
|
10
|
+
import {
|
|
11
|
+
CANONICAL_CATALOG,
|
|
12
|
+
getEffectiveCanonicalOffering,
|
|
13
|
+
getCanonicalOfferingNamesForAgent,
|
|
14
|
+
} from "../offerings/canonical-catalog.js";
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
|
|
19
|
+
/** The parsed offering.json config. */
|
|
20
|
+
|
|
21
|
+
export interface OfferingConfig {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
jobFee: number;
|
|
25
|
+
jobFeeType: "fixed" | "percentage";
|
|
26
|
+
requiredFunds: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LoadedOffering {
|
|
30
|
+
config: OfferingConfig;
|
|
31
|
+
handlers: OfferingHandlers;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const REQUIRED_FIELDS = [
|
|
35
|
+
"name",
|
|
36
|
+
"description",
|
|
37
|
+
"jobFee",
|
|
38
|
+
"jobFeeType",
|
|
39
|
+
"requiredFunds",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
function resolveOfferingsRoot(agentDirName: string): string {
|
|
43
|
+
return path.resolve(__dirname, "..", "offerings", agentDirName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load a named offering from `src/seller/offerings/<agentDirName>/<name>/`.
|
|
48
|
+
* Expects `offering.json` and `handlers.ts` in that directory.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadOffering(
|
|
51
|
+
offeringName: string,
|
|
52
|
+
agentDirName: string,
|
|
53
|
+
): Promise<LoadedOffering> {
|
|
54
|
+
const offeringsRoot = resolveOfferingsRoot(agentDirName);
|
|
55
|
+
const offeringDir = path.resolve(offeringsRoot, offeringName);
|
|
56
|
+
const relativePath = path.relative(offeringsRoot, offeringDir);
|
|
57
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid offering name: path traversal detected ("${offeringName}")`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// offering.json
|
|
64
|
+
const configPath = path.join(offeringDir, "offering.json");
|
|
65
|
+
if (!fs.existsSync(configPath)) {
|
|
66
|
+
throw new Error(`offering.json not found: ${configPath}`);
|
|
67
|
+
}
|
|
68
|
+
const parsedConfig = JSON.parse(
|
|
69
|
+
fs.readFileSync(configPath, "utf-8"),
|
|
70
|
+
) as OfferingConfig;
|
|
71
|
+
const effectiveCanonical = getEffectiveCanonicalOffering(offeringName);
|
|
72
|
+
const config: OfferingConfig = effectiveCanonical
|
|
73
|
+
? {
|
|
74
|
+
...parsedConfig,
|
|
75
|
+
name: effectiveCanonical.name,
|
|
76
|
+
jobFee: effectiveCanonical.jobFee,
|
|
77
|
+
jobFeeType: effectiveCanonical.jobFeeType,
|
|
78
|
+
requiredFunds: effectiveCanonical.requiredFunds,
|
|
79
|
+
}
|
|
80
|
+
: parsedConfig;
|
|
81
|
+
|
|
82
|
+
// handlers.ts (dynamically imported)
|
|
83
|
+
const handlersPath = path.join(offeringDir, "handlers.ts");
|
|
84
|
+
if (!fs.existsSync(handlersPath)) {
|
|
85
|
+
throw new Error(`handlers.ts not found: ${handlersPath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handlers = (await import(handlersPath)) as OfferingHandlers;
|
|
89
|
+
|
|
90
|
+
if (typeof handlers.executeJob !== "function") {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`handlers.ts in "${offeringName}" must export an executeJob function`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { config, handlers };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List all available offering names for a given agent.
|
|
101
|
+
*/
|
|
102
|
+
export function listOfferings(
|
|
103
|
+
agentDirName: string,
|
|
104
|
+
logger: Pick<typeof console, "warn"> = console,
|
|
105
|
+
): string[] {
|
|
106
|
+
const offeringsRoot = resolveOfferingsRoot(agentDirName);
|
|
107
|
+
if (!fs.existsSync(offeringsRoot)) return [];
|
|
108
|
+
return fs
|
|
109
|
+
.readdirSync(offeringsRoot, { withFileTypes: true })
|
|
110
|
+
.filter((d) => d.isDirectory())
|
|
111
|
+
.filter((d) => !d.name.startsWith("_"))
|
|
112
|
+
.flatMap((d) => {
|
|
113
|
+
const offeringDir = path.join(offeringsRoot, d.name);
|
|
114
|
+
const missingFiles = [
|
|
115
|
+
!fs.existsSync(path.join(offeringDir, "offering.json"))
|
|
116
|
+
? "offering.json"
|
|
117
|
+
: null,
|
|
118
|
+
!fs.existsSync(path.join(offeringDir, "handlers.ts"))
|
|
119
|
+
? "handlers.ts"
|
|
120
|
+
: null,
|
|
121
|
+
].filter((value): value is string => value !== null);
|
|
122
|
+
|
|
123
|
+
if (missingFiles.length > 0) {
|
|
124
|
+
logger.warn(
|
|
125
|
+
`[seller] Skipping incomplete offering "${d.name}": missing ${missingFiles.join(", ")}`,
|
|
126
|
+
);
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [d];
|
|
131
|
+
})
|
|
132
|
+
.map((d) => d.name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadOfferingConfigJson(
|
|
136
|
+
agentDirName: string,
|
|
137
|
+
offeringName: string,
|
|
138
|
+
): Record<string, unknown> {
|
|
139
|
+
const offeringsRoot = resolveOfferingsRoot(agentDirName);
|
|
140
|
+
const offeringDir = path.join(offeringsRoot, offeringName);
|
|
141
|
+
const offeringJsonPath = path.join(offeringDir, "offering.json");
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(offeringJsonPath)) {
|
|
144
|
+
throw new Error(`${offeringName}: offering.json not found`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const raw = fs.readFileSync(offeringJsonPath, "utf-8");
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (!parsed || typeof parsed !== "object") {
|
|
150
|
+
throw new Error(`${offeringName}: offering.json must be a JSON object`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parsed as Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeFeeToCents(value: unknown): number | undefined {
|
|
157
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Math.round(value * 100);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fail-fast preflight for the active seller directory.
|
|
166
|
+
*
|
|
167
|
+
* Startup must refuse to run unless the active agent directory contains
|
|
168
|
+
* exactly the canonical offerings with canonical pricing + required fields.
|
|
169
|
+
*/
|
|
170
|
+
export function assertCanonicalOfferingsOrThrow(
|
|
171
|
+
agentDirName: string,
|
|
172
|
+
offerings: string[],
|
|
173
|
+
logger: Pick<typeof console, "log" | "error"> = console,
|
|
174
|
+
): void {
|
|
175
|
+
const errors: string[] = [];
|
|
176
|
+
const discovered = new Set(offerings);
|
|
177
|
+
const expectedOfferings = getCanonicalOfferingNamesForAgent(agentDirName);
|
|
178
|
+
|
|
179
|
+
const missing = expectedOfferings.filter((name) => !discovered.has(name));
|
|
180
|
+
if (missing.length > 0) {
|
|
181
|
+
errors.push(`Missing canonical offerings: ${missing.join(", ")}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const extra = offerings.filter((name) => !expectedOfferings.includes(name));
|
|
185
|
+
if (extra.length > 0) {
|
|
186
|
+
errors.push(`Unexpected offering directories: ${extra.join(", ")}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const offeringName of expectedOfferings) {
|
|
190
|
+
if (!discovered.has(offeringName)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let config: Record<string, unknown>;
|
|
195
|
+
try {
|
|
196
|
+
config = loadOfferingConfigJson(agentDirName, offeringName);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const canonical = CANONICAL_CATALOG[offeringName];
|
|
203
|
+
|
|
204
|
+
for (const field of REQUIRED_FIELDS) {
|
|
205
|
+
if (config[field] === undefined || config[field] === null) {
|
|
206
|
+
errors.push(`${offeringName}: missing required field "${field}"`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (config.name !== canonical.name) {
|
|
211
|
+
errors.push(
|
|
212
|
+
`${offeringName}: name mismatch (got "${String(config.name)}", expected "${canonical.name}")`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const actualFeeCents = normalizeFeeToCents(config.jobFee);
|
|
217
|
+
const expectedFeeCents = normalizeFeeToCents(canonical.jobFee);
|
|
218
|
+
if (actualFeeCents === undefined || expectedFeeCents === undefined) {
|
|
219
|
+
errors.push(`${offeringName}: invalid numeric jobFee`);
|
|
220
|
+
} else if (actualFeeCents !== expectedFeeCents) {
|
|
221
|
+
errors.push(
|
|
222
|
+
`${offeringName}: jobFee mismatch (got ${String(config.jobFee)}, expected ${canonical.jobFee})`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (config.jobFeeType !== canonical.jobFeeType) {
|
|
227
|
+
errors.push(
|
|
228
|
+
`${offeringName}: jobFeeType mismatch (got "${String(config.jobFeeType)}", expected "${canonical.jobFeeType}")`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (config.requiredFunds !== canonical.requiredFunds) {
|
|
233
|
+
errors.push(
|
|
234
|
+
`${offeringName}: requiredFunds mismatch (got ${String(config.requiredFunds)}, expected ${String(canonical.requiredFunds)})`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const handlersPath = path.join(
|
|
239
|
+
resolveOfferingsRoot(agentDirName),
|
|
240
|
+
offeringName,
|
|
241
|
+
"handlers.ts",
|
|
242
|
+
);
|
|
243
|
+
if (!fs.existsSync(handlersPath)) {
|
|
244
|
+
errors.push(`${offeringName}: handlers.ts not found`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (errors.length > 0) {
|
|
249
|
+
const summary = [
|
|
250
|
+
`Canonical offering preflight failed for agent dir "${agentDirName}"`,
|
|
251
|
+
`Expected canonical offerings: ${expectedOfferings.length}`,
|
|
252
|
+
...errors.map((error) => `- ${error}`),
|
|
253
|
+
].join("\n");
|
|
254
|
+
logger.error(`[seller] ${summary}`);
|
|
255
|
+
throw new Error(summary);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
logger.log(
|
|
259
|
+
`[seller] Canonical offering preflight passed for "${agentDirName}" (${expectedOfferings.length}/${expectedOfferings.length})`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function logOfferingsStatus(
|
|
264
|
+
agentDirName: string,
|
|
265
|
+
offerings: string[],
|
|
266
|
+
logger: Pick<typeof console, "log" | "warn"> = console,
|
|
267
|
+
): void {
|
|
268
|
+
if (offerings.length === 0) {
|
|
269
|
+
logger.warn(
|
|
270
|
+
`[seller] WARNING: No offerings discovered for agent dir "${agentDirName}"`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
logger.log(
|
|
275
|
+
`[seller] Available offerings: ${offerings.length > 0 ? offerings.join(", ") : "(none)"}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { verifyPaymentProof } from "./paymentVerification.js";
|
|
3
|
+
import { MemoType, AcpJobPhase, type AcpMemoData } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** A well-formed EVM tx hash: 0x + 64 lowercase hex chars. */
|
|
10
|
+
const VALID_TX_HASH =
|
|
11
|
+
"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
|
12
|
+
|
|
13
|
+
function makeMemo(overrides: Partial<AcpMemoData> = {}): AcpMemoData {
|
|
14
|
+
return {
|
|
15
|
+
id: 1,
|
|
16
|
+
memoType: MemoType.MESSAGE,
|
|
17
|
+
content: "test",
|
|
18
|
+
nextPhase: AcpJobPhase.TRANSACTION,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Happy path — valid tx hashes
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("verifyPaymentProof", () => {
|
|
28
|
+
it("accepts PAYABLE_TRANSFER memo with valid tx hash", () => {
|
|
29
|
+
const memos = [
|
|
30
|
+
makeMemo({ memoType: MemoType.PAYABLE_TRANSFER, content: VALID_TX_HASH }),
|
|
31
|
+
];
|
|
32
|
+
const result = verifyPaymentProof(memos);
|
|
33
|
+
expect(result.verified).toBe(true);
|
|
34
|
+
expect(result.memo?.memoType).toBe(MemoType.PAYABLE_TRANSFER);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts TXHASH memo with valid tx hash", () => {
|
|
38
|
+
const memos = [
|
|
39
|
+
makeMemo({ memoType: MemoType.TXHASH, content: VALID_TX_HASH }),
|
|
40
|
+
];
|
|
41
|
+
const result = verifyPaymentProof(memos);
|
|
42
|
+
expect(result.verified).toBe(true);
|
|
43
|
+
expect(result.memo?.memoType).toBe(MemoType.TXHASH);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("accepts uppercase hex chars in tx hash", () => {
|
|
47
|
+
const memos = [
|
|
48
|
+
makeMemo({ memoType: MemoType.TXHASH, content: "0x" + "A".repeat(64) }),
|
|
49
|
+
];
|
|
50
|
+
expect(verifyPaymentProof(memos).verified).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accepts mixed-case hex chars in tx hash", () => {
|
|
54
|
+
const mixed =
|
|
55
|
+
"0xAbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890";
|
|
56
|
+
expect(
|
|
57
|
+
verifyPaymentProof([
|
|
58
|
+
makeMemo({ memoType: MemoType.TXHASH, content: mixed }),
|
|
59
|
+
]).verified,
|
|
60
|
+
).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// No payment memo
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
it("rejects when no payment memo exists", () => {
|
|
68
|
+
const memos = [
|
|
69
|
+
makeMemo({ memoType: MemoType.MESSAGE, content: "hello" }),
|
|
70
|
+
makeMemo({
|
|
71
|
+
memoType: MemoType.CONTEXT_URL,
|
|
72
|
+
content: "https://example.com",
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
const result = verifyPaymentProof(memos);
|
|
76
|
+
expect(result.verified).toBe(false);
|
|
77
|
+
expect(result.reason).toContain("No payment proof memo found");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects when memos array is empty", () => {
|
|
81
|
+
const result = verifyPaymentProof([]);
|
|
82
|
+
expect(result.verified).toBe(false);
|
|
83
|
+
expect(result.reason).toContain("No payment proof memo found");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Empty / whitespace content
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
it("rejects PAYABLE_TRANSFER memo with empty content", () => {
|
|
91
|
+
const memos = [
|
|
92
|
+
makeMemo({ memoType: MemoType.PAYABLE_TRANSFER, content: "" }),
|
|
93
|
+
];
|
|
94
|
+
const result = verifyPaymentProof(memos);
|
|
95
|
+
expect(result.verified).toBe(false);
|
|
96
|
+
expect(result.reason).toContain("empty content");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects PAYABLE_TRANSFER memo with whitespace-only content", () => {
|
|
100
|
+
const memos = [
|
|
101
|
+
makeMemo({ memoType: MemoType.PAYABLE_TRANSFER, content: " " }),
|
|
102
|
+
];
|
|
103
|
+
const result = verifyPaymentProof(memos);
|
|
104
|
+
expect(result.verified).toBe(false);
|
|
105
|
+
expect(result.reason).toContain("empty content");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Invalid tx hash formats (security: #783)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
it("rejects an arbitrary non-hex string (e.g. 'fake-tx-hash')", () => {
|
|
113
|
+
const result = verifyPaymentProof([
|
|
114
|
+
makeMemo({ memoType: MemoType.TXHASH, content: "fake-tx-hash" }),
|
|
115
|
+
]);
|
|
116
|
+
expect(result.verified).toBe(false);
|
|
117
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects a hash without the 0x prefix", () => {
|
|
121
|
+
const noPrefix =
|
|
122
|
+
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
|
123
|
+
const result = verifyPaymentProof([
|
|
124
|
+
makeMemo({ memoType: MemoType.TXHASH, content: noPrefix }),
|
|
125
|
+
]);
|
|
126
|
+
expect(result.verified).toBe(false);
|
|
127
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects a hash that is too short (63 hex chars after 0x)", () => {
|
|
131
|
+
const short = "0x" + "a".repeat(63);
|
|
132
|
+
const result = verifyPaymentProof([
|
|
133
|
+
makeMemo({ memoType: MemoType.TXHASH, content: short }),
|
|
134
|
+
]);
|
|
135
|
+
expect(result.verified).toBe(false);
|
|
136
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects a hash that is too long (65 hex chars after 0x)", () => {
|
|
140
|
+
const long = "0x" + "a".repeat(65);
|
|
141
|
+
const result = verifyPaymentProof([
|
|
142
|
+
makeMemo({ memoType: MemoType.TXHASH, content: long }),
|
|
143
|
+
]);
|
|
144
|
+
expect(result.verified).toBe(false);
|
|
145
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("rejects a hash with non-hex characters", () => {
|
|
149
|
+
const badHex = "0x" + "g".repeat(64);
|
|
150
|
+
const result = verifyPaymentProof([
|
|
151
|
+
makeMemo({ memoType: MemoType.TXHASH, content: badHex }),
|
|
152
|
+
]);
|
|
153
|
+
expect(result.verified).toBe(false);
|
|
154
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects '0x' alone (no hex digits)", () => {
|
|
158
|
+
const result = verifyPaymentProof([
|
|
159
|
+
makeMemo({ memoType: MemoType.TXHASH, content: "0x" }),
|
|
160
|
+
]);
|
|
161
|
+
expect(result.verified).toBe(false);
|
|
162
|
+
expect(result.reason).toMatch(/not a valid transaction hash/i);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("includes the offending value in the error reason for debuggability", () => {
|
|
166
|
+
const bad = "clearly-not-a-hash";
|
|
167
|
+
const result = verifyPaymentProof([
|
|
168
|
+
makeMemo({ memoType: MemoType.TXHASH, content: bad }),
|
|
169
|
+
]);
|
|
170
|
+
expect(result.verified).toBe(false);
|
|
171
|
+
expect(result.reason).toContain(bad);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Memo ordering
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
it("finds payment memo among other memos", () => {
|
|
179
|
+
const memos = [
|
|
180
|
+
makeMemo({ id: 1, memoType: MemoType.MESSAGE, content: "negotiation" }),
|
|
181
|
+
makeMemo({
|
|
182
|
+
id: 2,
|
|
183
|
+
memoType: MemoType.PAYABLE_REQUEST,
|
|
184
|
+
content: "request",
|
|
185
|
+
}),
|
|
186
|
+
makeMemo({ id: 3, memoType: MemoType.TXHASH, content: VALID_TX_HASH }),
|
|
187
|
+
];
|
|
188
|
+
const result = verifyPaymentProof(memos);
|
|
189
|
+
expect(result.verified).toBe(true);
|
|
190
|
+
expect(result.memo?.id).toBe(3);
|
|
191
|
+
expect(result.memo?.content).toBe(VALID_TX_HASH);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("prefers first matching payment memo", () => {
|
|
195
|
+
const memos = [
|
|
196
|
+
makeMemo({
|
|
197
|
+
id: 1,
|
|
198
|
+
memoType: MemoType.PAYABLE_TRANSFER,
|
|
199
|
+
content: VALID_TX_HASH,
|
|
200
|
+
}),
|
|
201
|
+
makeMemo({ id: 2, memoType: MemoType.TXHASH, content: VALID_TX_HASH }),
|
|
202
|
+
];
|
|
203
|
+
const result = verifyPaymentProof(memos);
|
|
204
|
+
expect(result.verified).toBe(true);
|
|
205
|
+
expect(result.memo?.id).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
});
|