Skip to content

Commit 7aa1460

Browse files
motxxclaude
andcommitted
feat(sdk): Provider waits for selection, runs producer, and publishes encrypted result (P2 chunk 7b)
Provider.serve advances steps 5-7 of the wire flow: 5. After publishing the kind 7000 quote, subscribe to the customer's kind 7000 status="processing" selection event referencing our request event id, addressed to us via the `p` tag. Time out after selectionTimeoutMs (default 60s). 6. If selected, invoke the lazy quote.produce() to obtain { data, proof }. Errors here mean the provider cannot fulfill; bail silently — the customer's HTLC locktime refund kicks in. 7. Build + publish the kind 6300 result event with NIP-44-encrypted content (schema + data + proof) addressed to the customer's ephemeral pubkey via buildQueryResponseEvent. Step 8-10 (NIP-44 DM preimage delivery + Cashu HTLC redemption) land in P2 chunk 7c. waitForSelection is implemented with the same wrapper-object pattern the customer uses for its result-event timeout, so no `let` reassignment trips prefer-const and the timeout is cleared on a successful selection (no Deno test-runner leak). Tests: 2 new (180 total in test:packages, was 178). End-to-end verifies producer runs after a delivered selection event, the kind 6300 content decrypts back to the producer's data + proof under the customer's NIP-44 keypair, and producer is not run when no selection arrives within the timeout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 42b453c commit 7aa1460

2 files changed

Lines changed: 250 additions & 10 deletions

File tree

packages/sdk/src/provider.test.ts

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
shouldQuote,
88
validateProviderOptions,
99
} from "./provider.ts";
10-
import { buildQueryRequestEvent } from "./events.ts";
1110
import {
11+
buildQueryRequestEvent,
12+
buildSelectionFeedbackEvent,
13+
} from "./events.ts";
14+
import {
15+
decryptNip44,
1216
generateKeypair,
1317
type Event,
1418
type Filter,
@@ -206,8 +210,10 @@ test("Provider.serve publishes a kind 7000 quote when handler returns a Provider
206210
let onEventRef: ((e: Event) => void) | null = null;
207211

208212
const relayClient = makeRelayClient({
209-
subscribe: (_filter: Filter, onEvent: (e: Event) => void): Subscription => {
210-
onEventRef = onEvent;
213+
subscribe: (filter: Filter, onEvent: (e: Event) => void): Subscription => {
214+
// Capture only the request subscription (kinds: [5300]); ignore
215+
// the per-job selection subscription so its timeout fires fast.
216+
if ((filter.kinds ?? []).includes(5300)) onEventRef = onEvent;
211217
return { close: () => {} };
212218
},
213219
publish: async (event: Event): Promise<PublishResult> => {
@@ -216,7 +222,11 @@ test("Provider.serve publishes a kind 7000 quote when handler returns a Provider
216222
},
217223
});
218224

219-
const provider = createProvider({ ...validOptions(), relayClient });
225+
const provider = createProvider({
226+
...validOptions(),
227+
relayClient,
228+
selectionTimeoutMs: 30,
229+
});
220230
const servePromise = provider.serve(async () => ({
221231
amountSats: 250,
222232
produce: async () => ({ data: null, proof: "p" }),
@@ -238,7 +248,9 @@ test("Provider.serve publishes a kind 7000 quote when handler returns a Provider
238248
locktime_seconds: Math.floor(Date.now() / 1000) + 3600,
239249
expires_at: Date.now() + 60_000,
240250
}));
241-
await new Promise((r) => setTimeout(r, 10));
251+
// Wait long enough for the per-job selection timeout (30ms) to fire,
252+
// so no setTimeout leaks across to the test runner.
253+
await new Promise((r) => setTimeout(r, 60));
242254
await provider.stop();
243255
await servePromise;
244256

@@ -288,6 +300,157 @@ test("Provider.serve declines requests where handler returns null (no publish)",
288300
expect(published).toHaveLength(0);
289301
});
290302

303+
test("Provider.serve waits for selection, runs producer, and publishes encrypted kind 6300 result", async () => {
304+
const published: Event[] = [];
305+
let onRequestEvent: ((e: Event) => void) | null = null;
306+
let onSelectionEvent: ((e: Event) => void) | null = null;
307+
let producerCalled = false;
308+
309+
const relayClient = makeRelayClient({
310+
subscribe: (filter: Filter, onEvent: (e: Event) => void): Subscription => {
311+
// First subscription is the request listener (kind 5300 only).
312+
// Second subscription (per job) is the selection listener
313+
// (kinds: [7000], #e: [requestId], authors: [customer]).
314+
const kinds = filter.kinds ?? [];
315+
if (kinds.includes(5300)) {
316+
onRequestEvent = onEvent;
317+
} else if (kinds.includes(7000)) {
318+
onSelectionEvent = onEvent;
319+
}
320+
return { close: () => {} };
321+
},
322+
publish: async (event: Event): Promise<PublishResult> => {
323+
published.push(event);
324+
return { successes: ["wss://relay.example.org"], failures: [] };
325+
},
326+
});
327+
328+
const provider = createProvider({
329+
...validOptions(),
330+
relayClient,
331+
selectionTimeoutMs: 200,
332+
});
333+
const servePromise = provider.serve(async () => ({
334+
amountSats: 200,
335+
produce: async () => {
336+
producerCalled = true;
337+
return { data: { hello: "world" }, proof: "pf-bytes" };
338+
},
339+
}));
340+
341+
await new Promise((r) => setTimeout(r, 5));
342+
if (onRequestEvent === null) throw new Error("request subscribe was not called");
343+
const fireRequest = onRequestEvent as (e: Event) => void;
344+
345+
// Customer sends a request.
346+
const requestEvent = buildQueryRequestEvent(customerKey, {
347+
query_id: "q1",
348+
schema: "io.anchr.tlsn-https.v1",
349+
predicate: { foo: "bar" },
350+
customer_pubkey: customerKey.publicKey,
351+
oracle_pubkey: ORACLE_A,
352+
mint_url: "https://mint.example.org",
353+
bounty_token: "cashuB",
354+
max_amount_sats: 1000,
355+
locktime_seconds: Math.floor(Date.now() / 1000) + 3600,
356+
expires_at: Date.now() + 60_000,
357+
});
358+
fireRequest(requestEvent);
359+
360+
// Wait for the provider to publish its quote and open the selection
361+
// subscription before we deliver the selection event.
362+
await new Promise((r) => setTimeout(r, 30));
363+
if (onSelectionEvent === null) throw new Error("selection subscribe was not called");
364+
const fireSelection = onSelectionEvent as (e: Event) => void;
365+
366+
// Customer announces selecting this provider, with a bound token.
367+
const selectionEvent = buildSelectionFeedbackEvent(customerKey, requestEvent.id, {
368+
status: "processing",
369+
selected_provider_pubkey: providerKey.publicKey,
370+
bound_token: "cashuBbound",
371+
});
372+
fireSelection(selectionEvent);
373+
374+
await new Promise((r) => setTimeout(r, 30));
375+
await provider.stop();
376+
await servePromise;
377+
378+
expect(producerCalled).toBe(true);
379+
// Two publishes from the provider: kind 7000 quote + kind 6300 result.
380+
expect(published).toHaveLength(2);
381+
expect(published[0].kind).toBe(7000);
382+
expect(published[1].kind).toBe(6300);
383+
384+
// The kind 6300 content is NIP-44-encrypted to the customer.
385+
const decrypted = decryptNip44(
386+
published[1].content,
387+
customerKey.secretKey,
388+
providerKey.publicKey,
389+
);
390+
const payload = JSON.parse(decrypted);
391+
expect(payload.schema).toBe("io.anchr.tlsn-https.v1");
392+
expect(payload.data).toEqual({ hello: "world" });
393+
expect(payload.proof).toBe("pf-bytes");
394+
});
395+
396+
test("Provider.serve never runs the producer when no selection event arrives within timeout", async () => {
397+
const published: Event[] = [];
398+
let onRequestEvent: ((e: Event) => void) | null = null;
399+
let producerCalled = false;
400+
401+
const relayClient = makeRelayClient({
402+
subscribe: (filter: Filter, onEvent: (e: Event) => void): Subscription => {
403+
const kinds = filter.kinds ?? [];
404+
if (kinds.includes(5300)) {
405+
onRequestEvent = onEvent;
406+
}
407+
return { close: () => {} };
408+
},
409+
publish: async (event: Event): Promise<PublishResult> => {
410+
published.push(event);
411+
return { successes: ["wss://relay.example.org"], failures: [] };
412+
},
413+
});
414+
415+
const provider = createProvider({
416+
...validOptions(),
417+
relayClient,
418+
selectionTimeoutMs: 30,
419+
});
420+
const servePromise = provider.serve(async () => ({
421+
amountSats: 100,
422+
produce: async () => {
423+
producerCalled = true;
424+
return { data: null, proof: "x" };
425+
},
426+
}));
427+
428+
await new Promise((r) => setTimeout(r, 5));
429+
if (onRequestEvent === null) throw new Error("request subscribe was not called");
430+
(onRequestEvent as (e: Event) => void)(buildQueryRequestEvent(customerKey, {
431+
query_id: "q1",
432+
schema: "io.anchr.tlsn-https.v1",
433+
predicate: {},
434+
customer_pubkey: customerKey.publicKey,
435+
oracle_pubkey: ORACLE_A,
436+
mint_url: "https://mint.example.org",
437+
bounty_token: "cashuB",
438+
max_amount_sats: 1000,
439+
locktime_seconds: Math.floor(Date.now() / 1000) + 3600,
440+
expires_at: Date.now() + 60_000,
441+
}));
442+
443+
// Wait beyond the selection timeout without delivering a selection.
444+
await new Promise((r) => setTimeout(r, 80));
445+
await provider.stop();
446+
await servePromise;
447+
448+
expect(producerCalled).toBe(false);
449+
// Only the kind 7000 quote was published; no kind 6300 result.
450+
expect(published).toHaveLength(1);
451+
expect(published[0].kind).toBe(7000);
452+
});
453+
291454
test("Provider.serve does not publish a quote that exceeds the request's maxAmountSats", async () => {
292455
const published: Event[] = [];
293456
let onEventRef: ((e: Event) => void) | null = null;

packages/sdk/src/provider.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@
1515
*/
1616

1717
import {
18+
buildQueryResponseEvent,
1819
buildQuoteFeedbackEvent,
1920
parseQueryRequestEvent,
21+
parseSelectionFeedbackEvent,
2022
} from "./events.ts";
2123
import {
2224
createRelayClient,
2325
type Event as NostrEvent,
26+
findTagValue,
2427
type Keypair,
2528
normalizeSecretKey,
2629
type RelayClient,
27-
signEvent as _signEvent,
2830
} from "./nostr.ts";
2931
import { getPublicKey } from "nostr-tools/pure";
3032
import type {
@@ -248,9 +250,84 @@ async function handleJob(
248250
);
249251
await ctx.relayClient.publish(quoteEvent);
250252

251-
// TODO(P2 chunk 8): wait for selection event, run quote.produce(),
252-
// encrypt+publish kind 6300, wait for oracle preimage DM, redeem HTLC.
253-
void quote.produce; // captured for next chunk
254-
void ctx.selectionTimeoutMs;
253+
// [step 5] Wait for the customer's selection event (kind 7000
254+
// status=processing) addressed to us. Time out after
255+
// selectionTimeoutMs — the customer may select a different
256+
// provider, in which case we never see a matching event.
257+
const selection = await waitForSelection(
258+
ctx,
259+
event.id,
260+
payload.customer_pubkey,
261+
);
262+
if (selection === null) return;
263+
264+
// [step 6] Run the lazy producer to generate the proof. Errors here
265+
// mean we cannot fulfill the request — silently bail (locktime
266+
// refunds the customer).
267+
let result: { data: unknown; proof: Uint8Array | string };
268+
try {
269+
result = await quote.produce();
270+
} catch {
271+
return;
272+
}
273+
274+
// [step 7] Build + publish the kind 6300 result event with the
275+
// response NIP-44-encrypted to the customer's pubkey.
276+
const responseEvent = buildQueryResponseEvent(
277+
ctx.identity,
278+
event.id,
279+
payload.customer_pubkey,
280+
{
281+
schema: payload.schema,
282+
data: result.data,
283+
proof: result.proof,
284+
},
285+
);
286+
await ctx.relayClient.publish(responseEvent);
287+
288+
// TODO(P2 chunk 7c): subscribe to NIP-44 DM (kind 4) from the
289+
// oracle for the preimage, then redeem the HTLC at the mint.
290+
void selection.bound_token; // captured for next chunk
255291
void ctx.cashuClient;
256292
}
293+
294+
/**
295+
* Wait for the customer's kind 7000 status=processing selection event
296+
* referencing our quote. Returns the parsed selection payload, or
297+
* null on timeout / no addressed-to-us event.
298+
*/
299+
function waitForSelection(
300+
ctx: JobContext,
301+
requestEventId: string,
302+
customerPubkey: string,
303+
): Promise<{ bound_token: string } | null> {
304+
return new Promise((resolve) => {
305+
// Mutable holder so the subscribe callback can clear the timer
306+
// and vice versa without `let` reassignment.
307+
const handles: {
308+
sub?: { close(): void };
309+
timeoutId?: ReturnType<typeof setTimeout>;
310+
} = {};
311+
handles.sub = ctx.relayClient.subscribe(
312+
{
313+
kinds: [7000],
314+
"#e": [requestEventId],
315+
authors: [customerPubkey],
316+
},
317+
(event) => {
318+
// The customer announces the selected provider via the `p` tag.
319+
const selectedPubkey = findTagValue(event, "p");
320+
if (selectedPubkey !== ctx.identity.publicKey) return;
321+
const parsed = parseSelectionFeedbackEvent(event);
322+
if (parsed === null) return;
323+
handles.sub?.close();
324+
if (handles.timeoutId !== undefined) clearTimeout(handles.timeoutId);
325+
resolve({ bound_token: parsed.bound_token });
326+
},
327+
);
328+
handles.timeoutId = setTimeout(() => {
329+
handles.sub?.close();
330+
resolve(null);
331+
}, ctx.selectionTimeoutMs);
332+
});
333+
}

0 commit comments

Comments
 (0)