Skip to content

Commit 7dae5d2

Browse files
motxxclaude
andcommitted
feat(sdk): publish Customer's kind 5300 Job Request via RelayClient (P2 chunk 4)
Adds the Nostr-publish step to Customer.request: - New module packages/sdk/src/events.ts defines QueryRequestPayload + buildQueryRequestEvent / parseQueryRequestEvent (round-trip) - Customer.request, after building the HTLC lock, builds a kind 5300 event carrying { schema, predicate, customer_pubkey, oracle_pubkey, bounty_token, max_amount_sats, locktime_seconds, expires_at } and publishes it via the configured RelayClient - CustomerOptions gains optional `relayClient` (built from `relays` when omitted; tests inject mocks) - RelayPublishError surfaces "0 successes" responses cleanly so the caller knows the request did not reach any relay - Steps 6-11 (subscribe / select / decrypt / verify) remain TODO; the not-implemented marker now reports the published event id Tests: 8 new (165 total in test:packages, was 157). Covers the round trip of buildQueryRequestEvent / parseQueryRequestEvent (incl. tag layout and rejection of malformed payloads), publish path through mock RelayClient, and RelayPublishError on zero accepts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a3409f5 commit 7dae5d2

8 files changed

Lines changed: 389 additions & 27 deletions

File tree

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
"test:ci": "deno test src/ packages/photo-bounty/ packages/tlsn-toolkit/ packages/cashu-frost-oracle/ packages/cashu-conditional-swap/ packages/core-runtime/ packages/core-cashu/ --allow-env --allow-read --allow-write --allow-net --allow-run --allow-sys '--ignore=src/**/*.integration.test.ts,packages/sdk/'",
7373
"test:example": "deno test --allow-all example/",
7474
"test": "deno test src/ packages/photo-bounty/ packages/tlsn-toolkit/ packages/cashu-frost-oracle/ packages/cashu-conditional-swap/ packages/core-runtime/ packages/core-cashu/ e2e/ --allow-env --allow-read --allow-write --allow-net --allow-run --allow-sys",
75-
"test:packages": "deno test packages/photo-bounty/ packages/tlsn-toolkit/ packages/cashu-frost-oracle/ packages/cashu-conditional-swap/ packages/core-runtime/ packages/core-cashu/ packages/sdk/src/customer.test.ts packages/sdk/src/provider.test.ts packages/sdk/src/schema.test.ts packages/sdk/src/nostr.test.ts packages/sdk/src/oracle.test.ts packages/sdk/src/cashu.test.ts --allow-env --allow-read --allow-write --allow-net --allow-run --allow-sys",
75+
"test:packages": "deno test packages/photo-bounty/ packages/tlsn-toolkit/ packages/cashu-frost-oracle/ packages/cashu-conditional-swap/ packages/core-runtime/ packages/core-cashu/ packages/sdk/src/customer.test.ts packages/sdk/src/provider.test.ts packages/sdk/src/schema.test.ts packages/sdk/src/nostr.test.ts packages/sdk/src/oracle.test.ts packages/sdk/src/cashu.test.ts packages/sdk/src/events.test.ts --allow-env --allow-read --allow-write --allow-net --allow-run --allow-sys",
7676
"test:pentest": "deno test e2e/pentest/ --allow-env --allow-read --allow-net --allow-run --allow-sys",
7777
"test:all": "./scripts/test-all.sh --local",
7878
"test:all:docker": "./scripts/test-all.sh --docker",

packages/sdk/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"src/nostr.ts",
2323
"src/oracle.ts",
2424
"src/cashu.ts",
25+
"src/events.ts",
2526
"README.md",
2627
"deno.json"
2728
],
@@ -44,7 +45,7 @@
4445
"strict": true
4546
},
4647
"tasks": {
47-
"test": "deno test --allow-env --allow-read --allow-net src/customer.test.ts src/provider.test.ts src/schema.test.ts src/nostr.test.ts src/oracle.test.ts src/cashu.test.ts"
48+
"test": "deno test --allow-env --allow-read --allow-net src/customer.test.ts src/provider.test.ts src/schema.test.ts src/nostr.test.ts src/oracle.test.ts src/cashu.test.ts src/events.test.ts"
4849
},
4950
"test": {
5051
"exclude": ["src/index.test.ts", "src/worker.test.ts"]

packages/sdk/src/customer.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
generateQueryId,
88
OracleWhitelistMismatchError,
99
pickOracleForRequest,
10+
RelayPublishError,
1011
selectCheapestQuote,
1112
validateCustomerOptions,
1213
} from "./customer.ts";
@@ -21,6 +22,13 @@ import type {
2122
RedeemHtlcParams,
2223
RedeemResult,
2324
} from "./cashu.ts";
25+
import type {
26+
Event,
27+
Filter,
28+
PublishResult,
29+
RelayClient,
30+
Subscription,
31+
} from "./nostr.ts";
2432
import type { CustomerOptions, Quote } from "./types.ts";
2533

2634
// --- Test doubles ---
@@ -66,12 +74,30 @@ function makeCashuClient(overrides?: Partial<CashuClient>): CashuClient {
6674
};
6775
}
6876

77+
function makeRelayClient(overrides?: Partial<RelayClient>): RelayClient {
78+
return {
79+
publish: overrides?.publish ?? (
80+
async (_event: Event): Promise<PublishResult> => ({
81+
successes: ["wss://relay.example.org"],
82+
failures: [],
83+
})
84+
),
85+
subscribe: overrides?.subscribe ?? (
86+
(_filter: Filter, _onEvent: (event: Event) => void): Subscription => ({
87+
close: () => {},
88+
})
89+
),
90+
close: overrides?.close ?? (() => {}),
91+
};
92+
}
93+
6994
const validOptions = (): CustomerOptions => ({
7095
oracles: [ORACLE_A, ORACLE_B],
7196
relays: ["wss://relay.example.org"],
7297
mint: "https://mint.example.org",
7398
oracleClient: makeOracleClient(),
7499
cashuClient: makeCashuClient(),
100+
relayClient: makeRelayClient(),
75101
});
76102

77103
// --- Validation ---
@@ -243,15 +269,57 @@ test("Customer.request propagates CashuMintError from buildHtlcLock", async () =
243269
).rejects.toThrow(CashuMintError);
244270
});
245271

246-
test("Customer.request reaches the not-implemented marker for steps 5-11 when prior steps succeed", async () => {
272+
test("Customer.request reaches the not-implemented marker for steps 6-11 after publish", async () => {
247273
const customer = createCustomer(validOptions());
248274
await expect(
249275
customer.request({
250276
spec: { schema: "io.anchr.tlsn-https.v1", predicate: { foo: "bar" } },
251277
payment: { maxAmount: 1000 },
252278
sourceProofs: [],
253279
}),
254-
).rejects.toThrow(/wire flow steps 5-11 not implemented/);
280+
).rejects.toThrow(/wire flow steps 6-11 not implemented/);
281+
});
282+
283+
test("Customer.request publishes a kind 5300 Job Request event via relayClient", async () => {
284+
const recorder: { event: Event | null } = { event: null };
285+
const relayClient = makeRelayClient({
286+
publish: async (event: Event): Promise<PublishResult> => {
287+
recorder.event = event;
288+
return { successes: ["wss://relay.example.org"], failures: [] };
289+
},
290+
});
291+
const customer = createCustomer({ ...validOptions(), relayClient });
292+
293+
await expect(
294+
customer.request({
295+
spec: { schema: "io.anchr.tlsn-https.v1", predicate: { foo: "bar" } },
296+
payment: { maxAmount: 500 },
297+
sourceProofs: [],
298+
}),
299+
).rejects.toThrow();
300+
301+
expect(recorder.event).not.toBe(null);
302+
if (recorder.event === null) throw new Error("unreachable");
303+
expect(recorder.event.kind).toBe(5300);
304+
expect(recorder.event.id).toMatch(/^[0-9a-f]{64}$/);
305+
});
306+
307+
test("Customer.request throws RelayPublishError when no relay accepts the event", async () => {
308+
const relayClient = makeRelayClient({
309+
publish: async (): Promise<PublishResult> => ({
310+
successes: [],
311+
failures: [{ relay: "wss://relay.example.org", reason: "rejected" }],
312+
}),
313+
});
314+
const customer = createCustomer({ ...validOptions(), relayClient });
315+
316+
await expect(
317+
customer.request({
318+
spec: { schema: "io.anchr.tlsn-https.v1", predicate: {} },
319+
payment: { maxAmount: 1000 },
320+
sourceProofs: [],
321+
}),
322+
).rejects.toThrow(RelayPublishError);
255323
});
256324

257325
// --- Pure helpers ---

packages/sdk/src/customer.ts

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ import {
2525
isSchemaUri,
2626
InvalidSchemaUriError,
2727
} from "./schema.ts";
28-
import { generateKeypair, type Keypair } from "./nostr.ts";
28+
import {
29+
createRelayClient,
30+
generateKeypair,
31+
type Keypair,
32+
type PublishResult,
33+
type RelayClient,
34+
} from "./nostr.ts";
35+
import { buildQueryRequestEvent, type QueryRequestPayload } from "./events.ts";
2936

3037
/**
3138
* Default quote-window in milliseconds. The SDK waits this long for
@@ -70,6 +77,17 @@ export class OracleWhitelistMismatchError extends Error {
7077
}
7178
}
7279

80+
/** Thrown when no relay accepted the published Job Request event. */
81+
export class RelayPublishError extends Error {
82+
constructor(public readonly result: PublishResult) {
83+
super(
84+
`No relay accepted the Job Request event ` +
85+
`(${result.failures.length} failures, 0 successes).`,
86+
);
87+
this.name = "RelayPublishError";
88+
}
89+
}
90+
7391
/**
7492
* Pick one oracle from the customer's whitelist for this request.
7593
*
@@ -186,28 +204,57 @@ export function createCustomer(options: CustomerOptions): Customer {
186204
sourceProofs: req.sourceProofs,
187205
});
188206

189-
// Captured for the next-milestone wire-flow steps; explicitly
190-
// referenced so static analysis treats them as used.
191-
void quoteWindowMs;
192-
void selector;
193-
void verifiers;
194-
void req.provider;
195-
void initialLock;
196-
void mint;
197-
198-
// TODO(P2 chunk 4-7): implement remaining wire flow:
199-
// 5. Publish kind 5300 Job Request (relays + bounty token)
200-
// 6. Subscribe to kind 7000 quotes for quoteWindowMs
201-
// 7. Select via selector(quotes), bind HTLC to provider pubkey
202-
// 8. Subscribe to kind 6300 result event
203-
// 9. Decrypt response payload via NIP-44
204-
// 10. Optionally call verifiers[req.spec.schema] for local verification
205-
// 11. Return RequestResult
206-
throw new Error(
207-
"Customer.request: wire flow steps 5-11 not implemented in v0.0.1. " +
208-
"Hash retrieved (oracle " + oraclePubkey + "), HTLC built. " +
209-
"Remaining steps tracked as P2 chunks 4-7.",
210-
);
207+
// [step 5] Build + publish the kind 5300 Job Request event. Use
208+
// an injected RelayClient when available (tests inject a mock);
209+
// otherwise build one from the configured relays for this call.
210+
const ownsRelayClient = options.relayClient === undefined;
211+
const relayClient: RelayClient = options.relayClient ?? createRelayClient(relays);
212+
213+
try {
214+
const requestPayload: QueryRequestPayload = {
215+
query_id: queryId,
216+
schema: req.spec.schema,
217+
predicate: req.spec.predicate,
218+
description: req.spec.description,
219+
customer_pubkey: identity.publicKey,
220+
oracle_pubkey: oraclePubkey,
221+
mint_url: mint,
222+
bounty_token: initialLock.token,
223+
max_amount_sats: req.payment.maxAmount,
224+
locktime_seconds: locktimeSeconds,
225+
expires_at: Date.now() + quoteWindowMs,
226+
};
227+
const requestEvent = buildQueryRequestEvent(identity, requestPayload);
228+
const publishResult = await relayClient.publish(requestEvent);
229+
230+
if (publishResult.successes.length === 0) {
231+
throw new RelayPublishError(publishResult);
232+
}
233+
234+
// Captured for the next-milestone wire-flow steps; explicitly
235+
// referenced so static analysis treats them as used.
236+
void selector;
237+
void verifiers;
238+
void req.provider;
239+
240+
// TODO(P2 chunk 5-7): implement remaining wire flow:
241+
// 6. Subscribe to kind 7000 quotes for quoteWindowMs
242+
// 7. Select via selector(quotes), bind HTLC to provider pubkey
243+
// 8. Subscribe to kind 6300 result event
244+
// 9. Decrypt response payload via NIP-44
245+
// 10. Optionally call verifiers[req.spec.schema] for local verification
246+
// 11. Return RequestResult
247+
throw new Error(
248+
"Customer.request: wire flow steps 6-11 not implemented in v0.0.1. " +
249+
`Job Request event ${requestEvent.id.slice(0, 16)}… ` +
250+
`accepted by ${publishResult.successes.length} of ` +
251+
`${publishResult.successes.length + publishResult.failures.length} relays.`,
252+
);
253+
} finally {
254+
if (ownsRelayClient) {
255+
relayClient.close();
256+
}
257+
}
211258
},
212259
};
213260
}

packages/sdk/src/events.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { test } from "@std/testing/bdd";
2+
import { expect } from "@std/expect";
3+
4+
import {
5+
buildQueryRequestEvent,
6+
parseQueryRequestEvent,
7+
type QueryRequestPayload,
8+
} from "./events.ts";
9+
import {
10+
findAllTagValues,
11+
findTagValue,
12+
generateKeypair,
13+
KIND_QUERY_REQUEST,
14+
} from "./nostr.ts";
15+
16+
function samplePayload(overrides?: Partial<QueryRequestPayload>): QueryRequestPayload {
17+
return {
18+
query_id: "query_abc",
19+
schema: "io.anchr.tlsn-https.v1",
20+
predicate: { target: "https://api.example.org" },
21+
description: "test",
22+
customer_pubkey: "11".repeat(32),
23+
oracle_pubkey: "22".repeat(32),
24+
mint_url: "https://mint.example.org",
25+
bounty_token: "cashuBfake",
26+
max_amount_sats: 1000,
27+
locktime_seconds: Math.floor(Date.now() / 1000) + 3600,
28+
expires_at: Date.now() + 600_000,
29+
...overrides,
30+
};
31+
}
32+
33+
test("buildQueryRequestEvent produces a kind 5300 signed event", () => {
34+
const identity = generateKeypair();
35+
const event = buildQueryRequestEvent(identity, samplePayload());
36+
37+
expect(event.kind).toBe(KIND_QUERY_REQUEST);
38+
expect(event.pubkey).toBe(identity.publicKey);
39+
expect(event.id).toMatch(/^[0-9a-f]{64}$/);
40+
expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
41+
});
42+
43+
test("buildQueryRequestEvent emits the d / t / p / schema tags", () => {
44+
const identity = generateKeypair();
45+
const payload = samplePayload();
46+
const event = buildQueryRequestEvent(identity, payload);
47+
48+
expect(findTagValue(event, "d")).toBe(payload.query_id);
49+
expect(findAllTagValues(event, "t")).toContain("anchr");
50+
expect(findTagValue(event, "p")).toBe(payload.oracle_pubkey);
51+
expect(findTagValue(event, "schema")).toBe(payload.schema);
52+
});
53+
54+
test("parseQueryRequestEvent recovers a payload from the built event (round-trip)", () => {
55+
const identity = generateKeypair();
56+
const payload = samplePayload();
57+
const event = buildQueryRequestEvent(identity, payload);
58+
const parsed = parseQueryRequestEvent(event);
59+
60+
expect(parsed).not.toBe(null);
61+
expect(parsed?.query_id).toBe(payload.query_id);
62+
expect(parsed?.schema).toBe(payload.schema);
63+
expect(parsed?.customer_pubkey).toBe(payload.customer_pubkey);
64+
expect(parsed?.oracle_pubkey).toBe(payload.oracle_pubkey);
65+
expect(parsed?.bounty_token).toBe(payload.bounty_token);
66+
expect(parsed?.max_amount_sats).toBe(payload.max_amount_sats);
67+
});
68+
69+
test("parseQueryRequestEvent returns null for a non-kind-5300 event", () => {
70+
const event = {
71+
kind: 1,
72+
pubkey: "00".repeat(32),
73+
id: "00".repeat(32),
74+
sig: "00".repeat(64),
75+
created_at: 0,
76+
content: JSON.stringify(samplePayload()),
77+
tags: [],
78+
};
79+
expect(parseQueryRequestEvent(event)).toBe(null);
80+
});
81+
82+
test("parseQueryRequestEvent returns null for unparseable content", () => {
83+
const event = {
84+
kind: KIND_QUERY_REQUEST,
85+
pubkey: "00".repeat(32),
86+
id: "00".repeat(32),
87+
sig: "00".repeat(64),
88+
created_at: 0,
89+
content: "{not json",
90+
tags: [],
91+
};
92+
expect(parseQueryRequestEvent(event)).toBe(null);
93+
});
94+
95+
test("parseQueryRequestEvent returns null when required fields are missing", () => {
96+
const event = {
97+
kind: KIND_QUERY_REQUEST,
98+
pubkey: "00".repeat(32),
99+
id: "00".repeat(32),
100+
sig: "00".repeat(64),
101+
created_at: 0,
102+
content: JSON.stringify({ query_id: "abc", schema: "io.x.y.v1" }),
103+
tags: [],
104+
};
105+
expect(parseQueryRequestEvent(event)).toBe(null);
106+
});

0 commit comments

Comments
 (0)