xytara 3.1.13 → 3.1.14

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.
Files changed (55) hide show
  1. package/.env.example +8 -1
  2. package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
  3. package/RELEASE_NOTES.md +6 -4
  4. package/lib/a2a_rpc.js +280 -280
  5. package/lib/account_auth.js +585 -585
  6. package/lib/agent_identity.js +162 -162
  7. package/lib/bolt_adapter.js +187 -187
  8. package/lib/bolt_payment_front.js +224 -224
  9. package/lib/checkout_processor_adapter.js +217 -217
  10. package/lib/command_flow.js +722 -722
  11. package/lib/commerce_artifacts.js +591 -591
  12. package/lib/commerce_bridge.js +274 -274
  13. package/lib/commerce_cases.js +399 -399
  14. package/lib/commerce_client.js +7694 -7694
  15. package/lib/commerce_quotes.js +29 -0
  16. package/lib/commerce_runtime.js +322 -322
  17. package/lib/execution_dedupe.js +86 -86
  18. package/lib/external_credit_bridge.js +142 -142
  19. package/lib/http_transport.js +81 -81
  20. package/lib/l402_adapter.js +241 -241
  21. package/lib/l402_payment_front.js +265 -265
  22. package/lib/mpp_adapter.js +237 -237
  23. package/lib/payment_config.js +253 -253
  24. package/lib/payment_verification.js +357 -357
  25. package/lib/provider_payout_execution.js +155 -155
  26. package/lib/provider_registry.js +842 -842
  27. package/lib/rate_limiter.js +81 -81
  28. package/lib/runtime_state_store.js +491 -369
  29. package/lib/settlement_bsv_live.js +600 -600
  30. package/lib/settlement_bsv_teranode.js +117 -117
  31. package/lib/stripe_mpp_payment_front.js +258 -258
  32. package/lib/x402_facilitator.js +191 -191
  33. package/package.json +5 -3
  34. package/scripts/state_backup_offsite.js +196 -0
  35. package/scripts/verify_a2a_rpc.js +105 -105
  36. package/scripts/verify_agent_identity.js +153 -153
  37. package/scripts/verify_all.js +1 -1
  38. package/scripts/verify_bolt_adapter.js +128 -128
  39. package/scripts/verify_cdp_facilitator.js +110 -110
  40. package/scripts/verify_checkout_processor.js +109 -109
  41. package/scripts/verify_commerce_bridge.js +177 -177
  42. package/scripts/verify_execution_dedupe.js +189 -189
  43. package/scripts/verify_execution_surfacing.js +76 -76
  44. package/scripts/verify_final_activation.js +191 -191
  45. package/scripts/verify_l402_adapter.js +86 -86
  46. package/scripts/verify_mpp_adapter.js +128 -128
  47. package/scripts/verify_production_readiness.js +268 -268
  48. package/scripts/verify_rate_limiter.js +52 -52
  49. package/scripts/verify_refund_credit.js +106 -106
  50. package/scripts/verify_release_candidate.js +81 -81
  51. package/scripts/verify_runtime_state_guard.js +117 -0
  52. package/scripts/verify_service.js +15421 -15421
  53. package/scripts/verify_verify_execute_settle.js +208 -208
  54. package/scripts/verify_x402_facilitator.js +286 -286
  55. package/server.js +11976 -11974
@@ -1,842 +1,842 @@
1
- "use strict";
2
-
3
- // Open execution-provider network (dynamic, state-backed) — the keystone that turns the
4
- // single-tenant env-pinned execution targets (external_execution_runtime.js) into a
5
- // two-sided network any provider can plug into: providers self-register their endpoint +
6
- // the capabilities they serve + their revenue-share; the runtime routes matching paid calls
7
- // to ACTIVE providers; settlement splits the money (provider cut vs platform take) and
8
- // records a per-provider ledger.
9
- //
10
- // Safe-by-default: a newly registered provider lands "pending" and is NOT routable until an
11
- // operator activates it, and a re-registration that changes the endpoint reverts an active
12
- // provider to pending (so a leaked registration bearer cannot silently redirect live paid
13
- // traffic). With an empty registry every function here is inert — zero behavior change.
14
-
15
- const crypto = require("crypto");
16
- const { clampTargetTimeoutMs, checkEndpointUrlShape } = require("./external_execution_policy");
17
-
18
- function normStr(value, fallback = "") {
19
- const s = typeof value === "string" ? value.trim() : "";
20
- return s || fallback;
21
- }
22
- function ensureObject(value) {
23
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
24
- }
25
- function normStrArray(value) {
26
- return Array.isArray(value) ? [...new Set(value.map((v) => normStr(v)).filter(Boolean))] : [];
27
- }
28
- function nowIso() {
29
- return new Date().toISOString();
30
- }
31
- function emptyRollup() {
32
- return { routed_calls: 0, settled_calls: 0, gross_minor: 0, provider_share_minor: 0, platform_take_minor: 0, paid_out_minor: 0 };
33
- }
34
-
35
- function emptyHealth() {
36
- return { status: "healthy", consecutive_failures: 0, last_success_at_iso: null, last_failure_at_iso: null, unhealthy_until_iso: null };
37
- }
38
-
39
- function emptyReputation() {
40
- return { success_count: 0, failure_count: 0 };
41
- }
42
-
43
- // Smoothed reputation score in (0,1] for ROUTING order. Uses an optimistic prior (+2 pseudo-
44
- // successes): a no-history provider scores 1.0 (OPTIMISTIC cold-start — it still gets a fair
45
- // first shot), and a single early transient failure scores ~0.67 (recoverable), NEVER 0.0 — so
46
- // a provider is not permanently doomed/starved by one bad call. Bad providers still sink over time.
47
- // Optional recency decay for the ROUTING score only (never money, never health exclusion). A
48
- // factor in (0,1) fades older observations on each update so recent performance dominates — a
49
- // provider that regressed sinks, one that recovered rises. Default 1.0 = NO decay, i.e. exactly
50
- // the lifetime-count behavior. Routing-only: raw lifetime counts stay intact for display.
51
- function reputationDecayFactor() {
52
- const n = Number(process.env.XYTARA_PROVIDER_REPUTATION_DECAY);
53
- return Number.isFinite(n) && n > 0 && n < 1 ? n : 1;
54
- }
55
-
56
- function reputationScore(rec) {
57
- const rep = (rec && rec.reputation) || emptyReputation();
58
- // Prefer the recency-weighted pseudo-counts when present; fall back to raw lifetime counts for a
59
- // no-history or pre-decay provider. With the default factor 1.0 the two are identical. Same
60
- // optimistic +2 prior either way (a new provider still scores 1.0 and gets a fair first shot).
61
- const success = Number.isFinite(Number(rep.decayed_success)) ? Number(rep.decayed_success) : (Number(rep.success_count) || 0);
62
- const failure = Number.isFinite(Number(rep.decayed_failure)) ? Number(rep.decayed_failure) : (Number(rep.failure_count) || 0);
63
- return (success + 2) / (success + failure + 2);
64
- }
65
-
66
- function reputationView(rec) {
67
- const rep = { ...emptyReputation(), ...((rec && rec.reputation) || {}) };
68
- const success = Number(rep.success_count) || 0;
69
- const failure = Number(rep.failure_count) || 0;
70
- const total = success + failure;
71
- return {
72
- success_count: success,
73
- failure_count: failure,
74
- total_calls: total,
75
- // RAW observed success rate for display; the smoothed reputationScore above is routing-only.
76
- success_rate: total === 0 ? null : Number((success / total).toFixed(4))
77
- };
78
- }
79
-
80
- function unhealthyThreshold() {
81
- const n = Number(process.env.XYTARA_PROVIDER_UNHEALTHY_THRESHOLD);
82
- return Number.isInteger(n) && n >= 1 ? n : 3;
83
- }
84
- function unhealthyCooldownMs() {
85
- const n = Number(process.env.XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS);
86
- // Clamp to <= 24h so Date.now()+cooldown can never overflow a valid Date (which would throw
87
- // in .toISOString() and silently drop the unhealthy transition, leaving a bad provider live).
88
- return Number.isFinite(n) && n >= 0 ? Math.min(n, 86400000) : 60000;
89
- }
90
- function failoverMax() {
91
- const n = Number(process.env.XYTARA_PROVIDER_FAILOVER_MAX);
92
- return Number.isInteger(n) && n >= 1 ? n : 3;
93
- }
94
-
95
- // A provider is routable w.r.t. health when it is healthy, OR it was marked unhealthy but the
96
- // cooldown has elapsed (half-open: give it one more chance). Healthy is preferred over
97
- // half-open at selection time. Returns null for routable, or "unhealthy_cooldown" when skipped.
98
- function healthRoutingState(health, nowMs) {
99
- const h = health || emptyHealth();
100
- if (h.status !== "unhealthy") return { routable: true, half_open: false };
101
- const until = h.unhealthy_until_iso ? Date.parse(h.unhealthy_until_iso) : 0;
102
- // null -> 0 -> elapsed; a garbage/unparseable timestamp -> NaN -> treat as elapsed (recover,
103
- // never strand a provider permanently excluded). Only a valid FUTURE timestamp excludes it.
104
- if (!Number.isFinite(until) || nowMs >= until) return { routable: true, half_open: true };
105
- return { routable: false, half_open: false };
106
- }
107
-
108
- const PAYOUT_STATUSES = new Set(["pending", "settled", "cancelled"]);
109
-
110
- const PROVIDER_STATUSES = new Set(["pending", "active", "suspended"]);
111
-
112
- function timingSafeStrEqual(a, b) {
113
- const x = Buffer.from(String(a || ""), "utf8");
114
- const y = Buffer.from(String(b || ""), "utf8");
115
- if (x.length !== y.length || x.length === 0) return false;
116
- try { return crypto.timingSafeEqual(x, y); } catch (_) { return false; }
117
- }
118
-
119
- function maxRevenueShareBps() {
120
- const raw = Number(process.env.XYTARA_PROVIDER_MAX_REVENUE_SHARE_BPS);
121
- return Number.isInteger(raw) && raw >= 0 && raw <= 10000 ? raw : 10000;
122
- }
123
-
124
- // Fields whose change on an ACTIVE provider must force re-approval (back to pending): the
125
- // endpoint it is called at, the capabilities it serves (which calls route to it), its
126
- // revenue split, and the token used to call it. Metadata (label/pricing/timeout) is exempt.
127
- function routingMaterialChanged(existing, next) {
128
- // JSON-encode the sorted, deduped members (not a delimiter-join) so a member containing
129
- // the separator cannot collide two different sets onto the same string.
130
- const sameSet = (a, b) => {
131
- const norm = (arr) => JSON.stringify([...new Set((arr || []).map(String))].sort());
132
- return norm(a) === norm(b);
133
- };
134
- return existing.endpoint_url !== next.endpoint_url
135
- || !sameSet(existing.task_refs, next.task_refs)
136
- || !sameSet(existing.tool_names, next.tool_names)
137
- || !sameSet(existing.integration_ids, next.integration_ids)
138
- || existing.revenue_share_bps !== next.revenue_share_bps
139
- || (existing.auth_token || null) !== (next.auth_token || null);
140
- }
141
-
142
- function ensureProviderCollections(state) {
143
- if (!state || typeof state !== "object") return;
144
- if (!(state.providerRegistry instanceof Map)) state.providerRegistry = new Map();
145
- if (!Array.isArray(state.providerSettlementLedger)) state.providerSettlementLedger = [];
146
- if (!Array.isArray(state.providerPayouts)) state.providerPayouts = [];
147
- }
148
-
149
- function rollupField(rec, field) {
150
- return rec && rec.rollup && Number.isFinite(Number(rec.rollup[field])) ? Number(rec.rollup[field]) : 0;
151
- }
152
-
153
- // Ledger rows and payouts belong to a REGISTRATION of a provider_id, not to the id itself: a
154
- // removed id that someone else re-registers must never see, inherit, or be credited by the
155
- // previous owner's rows. Legacy rows/records without an epoch match each other (null === null).
156
- function sameRegistrationEpoch(row, rec) {
157
- return ((row && row.registration_epoch) || null) === ((rec && rec.registration_epoch) || null);
158
- }
159
-
160
- // A payout mid-dispatch to the signer must not be cancellable: the signer may already have sent
161
- // the funds, and freeing the reservation would let a second payout re-send them. The guard
162
- // expires (process died during dispatch) so an operator can still reconcile a stuck record.
163
- const IN_FLIGHT_CANCEL_GUARD_MS = 5 * 60 * 1000;
164
- function payoutInFlight(payout) {
165
- if (!payout || payout.dispatch_state !== "in_flight") return false;
166
- const started = Date.parse(payout.dispatch_started_at_iso || "");
167
- return !Number.isFinite(started) || Date.now() - started < IN_FLIGHT_CANCEL_GUARD_MS;
168
- }
169
-
170
- // What a provider is owed, accounting for already-settled payouts AND outstanding pending ones
171
- // so two concurrent pending payouts can never over-draw the accrued share.
172
- // accrued = cumulative provider_share credited by settlements
173
- // paid_out = cumulative confirmed (settled) payouts
174
- // pending = sum of not-yet-confirmed payouts
175
- // available = accrued - paid_out - pending (the max a NEW payout may claim)
176
- // owed = accrued - paid_out (total still outstanding, incl. pending)
177
- function computeProviderPayoutState(state, providerId) {
178
- ensureProviderCollections(state);
179
- const rec = state.providerRegistry.get(normStr(providerId));
180
- if (!rec) return null;
181
- const accrued = rollupField(rec, "provider_share_minor");
182
- const paidOut = rollupField(rec, "paid_out_minor");
183
- const pending = state.providerPayouts
184
- .filter((p) => p.provider_id === rec.provider_id && sameRegistrationEpoch(p, rec) && p.status === "pending")
185
- .reduce((sum, p) => sum + (Number(p.amount_minor) || 0), 0);
186
- return {
187
- provider_id: rec.provider_id,
188
- payout_address: rec.payout_address || null,
189
- payout_network: rec.payout_network || null,
190
- payout_asset: rec.payout_asset || null,
191
- currency: "USD_CENTS",
192
- accrued_minor: accrued,
193
- paid_out_minor: paidOut,
194
- pending_minor: pending,
195
- available_minor: Math.max(0, accrued - paidOut - pending),
196
- owed_minor: Math.max(0, accrued - paidOut)
197
- };
198
- }
199
-
200
- // Never leaks auth_token — callers that need to CALL the provider read the stored record
201
- // directly (resolveRegisteredTarget); everything buyer/operator-facing uses this view.
202
- function buildProviderPublicView(rec) {
203
- if (!rec) return null;
204
- return {
205
- provider_id: rec.provider_id,
206
- label: rec.label,
207
- endpoint_url: rec.endpoint_url,
208
- task_refs: rec.task_refs,
209
- tool_names: rec.tool_names,
210
- integration_ids: rec.integration_ids,
211
- revenue_share_bps: rec.revenue_share_bps,
212
- pricing: rec.pricing,
213
- status: rec.status,
214
- timeout_ms: rec.timeout_ms,
215
- auth_bound: Boolean(rec.auth_token),
216
- payout_bound: Boolean(rec.payout_address),
217
- payout_network: rec.payout_network || null,
218
- payout_asset: rec.payout_asset || null,
219
- registered_at_iso: rec.registered_at_iso,
220
- activated_at_iso: rec.activated_at_iso,
221
- updated_at_iso: rec.updated_at_iso,
222
- rollup: { ...emptyRollup(), ...(rec.rollup || {}) },
223
- health: { ...emptyHealth(), ...(rec.health || {}) },
224
- reputation: reputationView(rec)
225
- };
226
- }
227
-
228
- // POST register (or idempotent update keyed on provider_id).
229
- function registerProvider(state, input) {
230
- ensureProviderCollections(state);
231
- const b = ensureObject(input);
232
- const endpointUrl = normStr(b.endpoint_url);
233
- if (!endpointUrl || !/^https?:\/\//i.test(endpointUrl)) {
234
- return { ok: false, reason: "endpoint_url_required" };
235
- }
236
- // Endpoint policy (strict on hosted deploys): https only, no loopback/private/link-local/metadata
237
- // hosts, no embedded credentials — the runtime POSTs to this URL, so it must not be usable as a
238
- // proxy into the runtime's own network. Re-checked (with DNS) at dispatch time.
239
- const endpointPolicy = checkEndpointUrlShape(endpointUrl);
240
- if (!endpointPolicy.ok) {
241
- return { ok: false, reason: endpointPolicy.reason, detail: endpointPolicy.detail || null, endpoint_policy: endpointPolicy.policy };
242
- }
243
- const taskRefs = normStrArray(b.task_refs);
244
- const toolNames = normStrArray(b.tool_names);
245
- const integrationIds = normStrArray(b.integration_ids);
246
- if (taskRefs.length + toolNames.length + integrationIds.length < 1) {
247
- return { ok: false, reason: "capability_selector_required" };
248
- }
249
- const shareBps = Number(b.revenue_share_bps);
250
- if (!Number.isInteger(shareBps) || shareBps < 0 || shareBps > 10000) {
251
- return { ok: false, reason: "revenue_share_bps_invalid" };
252
- }
253
- if (shareBps > maxRevenueShareBps()) {
254
- return { ok: false, reason: "revenue_share_bps_exceeds_max", max_revenue_share_bps: maxRevenueShareBps() };
255
- }
256
- const providerId = normStr(b.provider_id) || `provider_${crypto.randomBytes(6).toString("hex")}`;
257
- const existing = state.providerRegistry.get(providerId) || null;
258
-
259
- // Per-provider ownership: a new provider_id mints an owner_secret (returned ONCE); any
260
- // re-registration of an existing provider_id MUST present that secret. This stops one
261
- // holder of the shared registration bearer from mutating, re-pointing, or DoS-ing another
262
- // provider's record.
263
- if (existing && !timingSafeStrEqual(normStr(b.owner_secret), existing.owner_secret)) {
264
- return { ok: false, reason: "provider_owner_mismatch" };
265
- }
266
- const ownerSecret = existing ? existing.owner_secret : `prvown_${crypto.randomBytes(24).toString("hex")}`;
267
- const authToken = normStr(b.auth_token) || (existing && existing.auth_token) || null;
268
-
269
- // Payout destination (where this provider's revenue-share is paid). Owner-protected like
270
- // the rest of the record; NOT routing-material (changing it does not affect execution, so
271
- // it does not revert an active provider to pending). An eip155 address is shape-checked.
272
- const payoutNetwork = normStr(b.payout_network) || (existing && existing.payout_network) || "eip155:8453";
273
- const payoutAsset = normStr(b.payout_asset) || (existing && existing.payout_asset) || "USDC";
274
- const payoutAddress = normStr(b.payout_address) || (existing && existing.payout_address) || null;
275
- if (payoutAddress && /^eip155/i.test(payoutNetwork) && !/^0x[0-9a-fA-F]{40}$/.test(payoutAddress)) {
276
- return { ok: false, reason: "payout_address_invalid" };
277
- }
278
-
279
- const nextRouting = {
280
- endpoint_url: endpointUrl,
281
- task_refs: taskRefs,
282
- tool_names: toolNames,
283
- integration_ids: integrationIds,
284
- revenue_share_bps: shareBps,
285
- auth_token: authToken
286
- };
287
- // New -> pending. Re-registration that changes ANY routing-material field (endpoint, served
288
- // capabilities, revenue split, or call token) reverts an ACTIVE provider to pending, so an
289
- // expanded or altered capability can never receive paid traffic without operator re-approval.
290
- let status = existing ? existing.status : "pending";
291
- if (existing && existing.status === "active" && routingMaterialChanged(existing, nextRouting)) {
292
- status = "pending";
293
- }
294
-
295
- const record = {
296
- provider_id: providerId,
297
- label: normStr(b.label, providerId),
298
- endpoint_url: endpointUrl,
299
- task_refs: taskRefs,
300
- tool_names: toolNames,
301
- integration_ids: integrationIds,
302
- revenue_share_bps: shareBps,
303
- pricing: ensureObject(b.pricing),
304
- payout_address: payoutAddress,
305
- payout_network: payoutNetwork,
306
- payout_asset: payoutAsset,
307
- auth_token: authToken,
308
- owner_secret: ownerSecret,
309
- // Minted once per registration; every ledger row / payout is stamped with it (see
310
- // sameRegistrationEpoch). A re-registration of an existing record keeps its epoch.
311
- registration_epoch: existing ? (existing.registration_epoch || null) : `reg_${crypto.randomBytes(6).toString("hex")}`,
312
- // Capped (30s): a longer call could not settle inside the x402 authorization window.
313
- timeout_ms: clampTargetTimeoutMs(b.timeout_ms),
314
- status,
315
- registered_by: normStr(b.registered_by) || (existing && existing.registered_by) || null,
316
- registered_at_iso: existing ? existing.registered_at_iso : nowIso(),
317
- activated_at_iso: status === "active" ? (existing && existing.activated_at_iso) || null : (existing && existing.activated_at_iso) || null,
318
- updated_at_iso: nowIso(),
319
- rollup: existing ? existing.rollup : emptyRollup(),
320
- health: existing ? (existing.health || emptyHealth()) : emptyHealth(),
321
- reputation: existing ? (existing.reputation || emptyReputation()) : emptyReputation()
322
- };
323
- state.providerRegistry.set(providerId, record);
324
- const result = { ok: true, provider_id: providerId, status, provider: buildProviderPublicView(record) };
325
- if (!existing) result.owner_secret = ownerSecret; // returned exactly once, at first registration
326
- return result;
327
- }
328
-
329
- // Operator activate / suspend / re-pend.
330
- function setProviderStatus(state, providerId, status) {
331
- ensureProviderCollections(state);
332
- const nextStatus = normStr(status);
333
- if (!PROVIDER_STATUSES.has(nextStatus)) return { ok: false, reason: "invalid_status" };
334
- const rec = state.providerRegistry.get(normStr(providerId));
335
- if (!rec) return { ok: false, reason: "provider_not_found" };
336
- const next = {
337
- ...rec,
338
- status: nextStatus,
339
- activated_at_iso: nextStatus === "active" ? rec.activated_at_iso || nowIso() : rec.activated_at_iso,
340
- updated_at_iso: nowIso()
341
- };
342
- state.providerRegistry.set(rec.provider_id, next);
343
- return { ok: true, provider: buildProviderPublicView(next) };
344
- }
345
-
346
- function getProvider(state, providerId) {
347
- ensureProviderCollections(state);
348
- return state.providerRegistry.get(normStr(providerId)) || null;
349
- }
350
-
351
- // Operator removes a provider from the registry (e.g. to purge suspended/spam records and free
352
- // the pending pool). Refuses when the provider still has an unpaid owed balance unless `force`,
353
- // so a provider with earnings can't be silently dropped.
354
- function removeProvider(state, providerId, options = {}) {
355
- ensureProviderCollections(state);
356
- const id = normStr(providerId);
357
- const rec = state.providerRegistry.get(id);
358
- if (!rec) return { ok: false, reason: "provider_not_found" };
359
- const owed = Math.max(0, rollupField(rec, "provider_share_minor") - rollupField(rec, "paid_out_minor"));
360
- if (owed > 0 && !(options && options.force)) {
361
- return { ok: false, reason: "provider_has_unpaid_balance", owed_minor: owed };
362
- }
363
- // Cancel this registration's pending payouts so they can never be confirmed against (or reserve
364
- // balance of) a later registration of the same id. A payout mid-dispatch may already have been
365
- // sent, so it is left pending for manual reconciliation. Ledger rows are retained as the
366
- // operator's audit trail; they are epoch-scoped so a new registration never sees them.
367
- const cancelledPendingPayouts = [];
368
- const inFlightPayouts = [];
369
- for (const payout of state.providerPayouts) {
370
- if (payout.provider_id !== id || payout.status !== "pending" || !sameRegistrationEpoch(payout, rec)) continue;
371
- if (payoutInFlight(payout)) {
372
- inFlightPayouts.push(payout.payout_id);
373
- continue;
374
- }
375
- payout.status = "cancelled";
376
- payout.cancelled_at_iso = nowIso();
377
- payout.note = [payout.note, "provider_removed"].filter(Boolean).join("; ");
378
- cancelledPendingPayouts.push(payout.payout_id);
379
- }
380
- state.providerRegistry.delete(id);
381
- return {
382
- ok: true,
383
- removed: id,
384
- forced: Boolean(options && options.force),
385
- owed_minor_at_removal: owed,
386
- cancelled_pending_payouts: cancelledPendingPayouts,
387
- in_flight_payouts_left_pending: inFlightPayouts
388
- };
389
- }
390
-
391
- function listProviders(state, filters = {}) {
392
- ensureProviderCollections(state);
393
- let items = Array.from(state.providerRegistry.values());
394
- const status = normStr(filters && filters.status);
395
- if (status) items = items.filter((p) => p.status === status);
396
- return items;
397
- }
398
-
399
- // Resolve an ACTIVE registered provider for a call. Mirrors the env-target selectors
400
- // (task_ref / tool_name / integration_id) but a provider matches if the call hits ANY of
401
- // the capabilities it declared it serves. Returns a target shaped for executeExternalTarget.
402
- function providerMatchesCall(p, taskRefs, toolName, integrationIds) {
403
- if (p.status !== "active") return false;
404
- if (p.task_refs.length && p.task_refs.some((r) => taskRefs.includes(r))) return true;
405
- if (p.tool_names.length && toolName && p.tool_names.includes(toolName)) return true;
406
- if (p.integration_ids.length && p.integration_ids.some((id) => integrationIds.has(id))) return true;
407
- return false;
408
- }
409
-
410
- function targetFromProvider(p) {
411
- return {
412
- target_id: p.provider_id,
413
- endpoint_url: p.endpoint_url,
414
- task_ref: null,
415
- tool_name: null,
416
- integration_id: null,
417
- provider: p.provider_id,
418
- auth_token: p.auth_token,
419
- timeout_ms: p.timeout_ms,
420
- enabled: true,
421
- registered_provider_id: p.provider_id
422
- };
423
- }
424
-
425
- // All ACTIVE + health-routable providers matching the call, ordered healthy-first then
426
- // half-open (recovered past their cooldown), capped at the failover max — so a caller can try
427
- // them in sequence and fail over when one is down. Currently-unhealthy (in-cooldown) providers
428
- // are excluded.
429
- function extractCallSelectors(payload, quote) {
430
- const body = ensureObject(payload && payload.body);
431
- const taskRefs = Array.isArray(payload && payload.tasks) && payload.tasks.length > 0
432
- ? payload.tasks.map((t) => normStr(t && t.task_ref)).filter(Boolean)
433
- : [normStr(payload && payload.task_ref)].filter(Boolean);
434
- const toolName = normStr(body.tool_name) || null;
435
- const integrationIds = new Set(
436
- []
437
- .concat(normStr(payload && payload.integration_id) ? [normStr(payload && payload.integration_id)] : [])
438
- .concat(Array.isArray(payload && payload.integration_ids) ? payload.integration_ids.map((v) => normStr(v)).filter(Boolean) : [])
439
- .concat(Array.isArray(quote && quote.integration_ids) ? quote.integration_ids.map((v) => normStr(v)).filter(Boolean) : [])
440
- );
441
- return { taskRefs, toolName, integrationIds };
442
- }
443
-
444
- // Whether ANY active provider matches the call, regardless of health. Lets the caller tell
445
- // "no provider serves this capability" (fall through to local handling) apart from "providers
446
- // serve it but are all currently unhealthy" (fail — don't charge for undelivered work).
447
- function hasActiveMatchingProvider(state, payload, quote) {
448
- ensureProviderCollections(state);
449
- if (state.providerRegistry.size < 1) return false;
450
- const { taskRefs, toolName, integrationIds } = extractCallSelectors(payload, quote);
451
- for (const p of state.providerRegistry.values()) {
452
- if (providerMatchesCall(p, taskRefs, toolName, integrationIds)) return true;
453
- }
454
- return false;
455
- }
456
-
457
- function resolveRegisteredTargets(state, payload, quote) {
458
- ensureProviderCollections(state);
459
- if (state.providerRegistry.size < 1) return [];
460
- const { taskRefs, toolName, integrationIds } = extractCallSelectors(payload, quote);
461
- const nowMs = Date.now();
462
- const healthy = [];
463
- const halfOpen = [];
464
- for (const p of state.providerRegistry.values()) {
465
- if (!providerMatchesCall(p, taskRefs, toolName, integrationIds)) continue;
466
- const hs = healthRoutingState(p.health, nowMs);
467
- if (!hs.routable) continue;
468
- (hs.half_open ? halfOpen : healthy).push(p);
469
- }
470
- // Within the healthy tier, prefer higher lifetime reputation (stable sort keeps registration
471
- // order for ties; a no-history provider scores 1.0 optimistically so it still gets routed and
472
- // can build a record). Half-open (recovering) providers always come after healthy ones.
473
- healthy.sort((a, b) => reputationScore(b) - reputationScore(a));
474
- return healthy.concat(halfOpen).map(targetFromProvider).slice(0, failoverMax());
475
- }
476
-
477
- function resolveRegisteredTarget(state, payload, quote) {
478
- const targets = resolveRegisteredTargets(state, payload, quote);
479
- return targets.length > 0 ? targets[0] : null;
480
- }
481
-
482
- // Update a provider's health after a routed call. Success resets to healthy; failure increments
483
- // consecutive_failures and, on crossing the threshold, marks it unhealthy with a cooldown
484
- // (excluded from routing until the cooldown elapses, then half-open for one retry).
485
- function recordProviderHealth(state, providerId, result = {}) {
486
- ensureProviderCollections(state);
487
- const rec = state.providerRegistry.get(normStr(providerId));
488
- if (!rec) return { ok: false, reason: "provider_not_found" };
489
- const h = rec.health || emptyHealth();
490
- const stamp = nowIso();
491
- let next;
492
- if (result && result.ok) {
493
- next = { status: "healthy", consecutive_failures: 0, last_success_at_iso: stamp, last_failure_at_iso: h.last_failure_at_iso || null, unhealthy_until_iso: null };
494
- } else {
495
- const failures = (Number(h.consecutive_failures) || 0) + 1;
496
- const nowUnhealthy = failures >= unhealthyThreshold();
497
- next = {
498
- status: nowUnhealthy ? "unhealthy" : h.status,
499
- consecutive_failures: failures,
500
- last_success_at_iso: h.last_success_at_iso || null,
501
- last_failure_at_iso: stamp,
502
- unhealthy_until_iso: nowUnhealthy ? new Date(Date.now() + unhealthyCooldownMs()).toISOString() : (h.unhealthy_until_iso || null)
503
- };
504
- }
505
- const rep = rec.reputation || emptyReputation();
506
- const ok = Boolean(result && result.ok);
507
- // Raw lifetime counts (for display / success_rate) — unchanged integer-increment semantics.
508
- const rawSuccess = (Number(rep.success_count) || 0) + (ok ? 1 : 0);
509
- const rawFailure = (Number(rep.failure_count) || 0) + (ok ? 0 : 1);
510
- // Recency-weighted pseudo-counts (routing order). Seeded from the raw counts the first time, then
511
- // decayed each update. With the default factor 1.0 these stay equal to the raw counts exactly.
512
- const decay = reputationDecayFactor();
513
- const baseSuccess = Number.isFinite(Number(rep.decayed_success)) ? Number(rep.decayed_success) : (Number(rep.success_count) || 0);
514
- const baseFailure = Number.isFinite(Number(rep.decayed_failure)) ? Number(rep.decayed_failure) : (Number(rep.failure_count) || 0);
515
- const nextRep = {
516
- success_count: rawSuccess,
517
- failure_count: rawFailure,
518
- decayed_success: baseSuccess * decay + (ok ? 1 : 0),
519
- decayed_failure: baseFailure * decay + (ok ? 0 : 1)
520
- };
521
- state.providerRegistry.set(rec.provider_id, { ...rec, health: next, reputation: nextRep, updated_at_iso: stamp });
522
- return { ok: true, health: next, reputation: nextRep };
523
- }
524
-
525
- // Count a routed dispatch (whether or not it later settles).
526
- function recordProviderRouting(state, providerId) {
527
- ensureProviderCollections(state);
528
- const rec = state.providerRegistry.get(normStr(providerId));
529
- if (!rec) return { ok: false, reason: "provider_not_found" };
530
- const roll = rec.rollup || emptyRollup();
531
- state.providerRegistry.set(rec.provider_id, {
532
- ...rec,
533
- rollup: { ...roll, routed_calls: (roll.routed_calls || 0) + 1 },
534
- updated_at_iso: nowIso()
535
- });
536
- return { ok: true };
537
- }
538
-
539
- // Split a settled amount for a provider and record a ledger entry. provider_share is floored
540
- // (platform keeps the rounding remainder); amounts are integer minor units.
541
- function recordProviderSettlement(state, input) {
542
- ensureProviderCollections(state);
543
- const b = ensureObject(input);
544
- const rec = state.providerRegistry.get(normStr(b.provider_id));
545
- if (!rec) return { ok: false, reason: "provider_not_found" };
546
- const gross = Math.max(0, Math.round(Number(b.amount_minor) || 0));
547
- const providerShare = Math.floor((gross * rec.revenue_share_bps) / 10000);
548
- const platformTake = gross - providerShare;
549
- const optionalMinor = (value) => (value !== undefined && value !== null && Number.isFinite(Number(value)) ? Math.round(Number(value)) : null);
550
- const entry = {
551
- settlement_entry_id: `provset_${crypto.randomBytes(6).toString("hex")}`,
552
- provider_id: rec.provider_id,
553
- registration_epoch: rec.registration_epoch || null,
554
- transaction_id: normStr(b.transaction_id) || null,
555
- settlement_ref: normStr(b.settlement_ref) || null,
556
- task_ref: normStr(b.task_ref) || null,
557
- currency: normStr(b.currency) || null,
558
- // What the accrual was based on (the caller reconciles quote vs. what actually settled).
559
- settlement_basis: normStr(b.settlement_basis) || "quote",
560
- quoted_minor: optionalMinor(b.quoted_minor),
561
- settled_minor: optionalMinor(b.settled_minor),
562
- reconciled: b.reconciled === undefined ? true : Boolean(b.reconciled),
563
- gross_minor: gross,
564
- revenue_share_bps: rec.revenue_share_bps,
565
- provider_share_minor: providerShare,
566
- platform_take_minor: platformTake,
567
- recorded_at_iso: nowIso()
568
- };
569
- state.providerSettlementLedger.push(entry);
570
- const roll = rec.rollup || emptyRollup();
571
- state.providerRegistry.set(rec.provider_id, {
572
- ...rec,
573
- rollup: {
574
- ...roll,
575
- settled_calls: (roll.settled_calls || 0) + 1,
576
- gross_minor: (roll.gross_minor || 0) + gross,
577
- provider_share_minor: (roll.provider_share_minor || 0) + providerShare,
578
- platform_take_minor: (roll.platform_take_minor || 0) + platformTake
579
- },
580
- updated_at_iso: nowIso()
581
- });
582
- return { ok: true, settlement: entry };
583
- }
584
-
585
- // Optional `registration_epoch` filter (pass null to select legacy un-stamped rows) scopes the
586
- // rows to ONE registration of the id; without it the operator sees the full audit trail.
587
- function hasEpochFilter(filters) {
588
- return Boolean(filters) && Object.prototype.hasOwnProperty.call(filters, "registration_epoch");
589
- }
590
-
591
- function listProviderSettlements(state, filters = {}) {
592
- ensureProviderCollections(state);
593
- let items = state.providerSettlementLedger.slice();
594
- const providerId = normStr(filters && filters.provider_id);
595
- if (providerId) items = items.filter((e) => e.provider_id === providerId);
596
- if (hasEpochFilter(filters)) items = items.filter((e) => sameRegistrationEpoch(e, { registration_epoch: filters.registration_epoch }));
597
- return items;
598
- }
599
-
600
- // Create a payout CLAIM for a provider (operator action). Amount defaults to the full
601
- // available balance and can NEVER exceed available (accrued - paid - pending), so concurrent
602
- // pending payouts cannot over-draw. The runtime does NOT move funds here — it records the
603
- // intent; the operator executes the on-chain transfer and confirms it with the tx hash.
604
- function createProviderPayout(state, providerId, input = {}) {
605
- ensureProviderCollections(state);
606
- const rec = state.providerRegistry.get(normStr(providerId));
607
- if (!rec) return { ok: false, reason: "provider_not_found" };
608
- if (!rec.payout_address) return { ok: false, reason: "payout_address_not_set" };
609
- const payoutState = computeProviderPayoutState(state, rec.provider_id);
610
- const available = payoutState.available_minor;
611
- if (available <= 0) return { ok: false, reason: "nothing_payable", available_minor: available };
612
- const b = ensureObject(input);
613
- const requested = b.amount_minor !== undefined && b.amount_minor !== null ? Math.round(Number(b.amount_minor)) : available;
614
- if (!Number.isFinite(requested) || requested <= 0) return { ok: false, reason: "amount_invalid" };
615
- if (requested > available) return { ok: false, reason: "amount_exceeds_available", available_minor: available };
616
- const payout = {
617
- payout_id: `provpay_${crypto.randomBytes(6).toString("hex")}`,
618
- provider_id: rec.provider_id,
619
- registration_epoch: rec.registration_epoch || null,
620
- dispatch_state: null,
621
- dispatch_started_at_iso: null,
622
- payout_address: rec.payout_address,
623
- payout_network: rec.payout_network || null,
624
- payout_asset: rec.payout_asset || null,
625
- amount_minor: requested,
626
- currency: "USD_CENTS",
627
- status: "pending",
628
- tx_ref: null,
629
- created_at_iso: nowIso(),
630
- settled_at_iso: null,
631
- cancelled_at_iso: null,
632
- note: normStr(b.note) || null
633
- };
634
- state.providerPayouts.push(payout);
635
- return { ok: true, payout, payout_state: computeProviderPayoutState(state, rec.provider_id) };
636
- }
637
-
638
- function getProviderPayout(state, payoutId) {
639
- ensureProviderCollections(state);
640
- return state.providerPayouts.find((p) => p.payout_id === normStr(payoutId)) || null;
641
- }
642
-
643
- // Confirm a payout the operator executed on-chain. Idempotent: re-confirming a settled payout
644
- // returns it without double-crediting paid_out. Only a pending payout credits paid_out_minor.
645
- function confirmProviderPayout(state, payoutId, input = {}) {
646
- ensureProviderCollections(state);
647
- const payout = getProviderPayout(state, payoutId);
648
- if (!payout) return { ok: false, reason: "payout_not_found" };
649
- if (payout.status === "settled") return { ok: true, payout, idempotent: true };
650
- if (payout.status !== "pending") return { ok: false, reason: "payout_not_pending", status: payout.status };
651
- const txRef = normStr(ensureObject(input).tx_ref);
652
- if (!txRef) return { ok: false, reason: "tx_ref_required" };
653
- // A tx_ref may back only ONE settled payout. Otherwise two payouts credit paid_out for a
654
- // single on-chain transfer, silently under-paying the provider and burning reserved balance.
655
- if (state.providerPayouts.some((p) => p.status === "settled" && p.tx_ref === txRef && p.payout_id !== payout.payout_id)) {
656
- return { ok: false, reason: "duplicate_tx_ref" };
657
- }
658
- payout.status = "settled";
659
- payout.tx_ref = txRef;
660
- payout.settled_at_iso = nowIso();
661
- payout.dispatch_state = null;
662
- const rec = state.providerRegistry.get(payout.provider_id);
663
- // Credit paid_out ONLY on the registration the payout belongs to — a payout from a previous
664
- // (removed) registration of the same id is recorded but never credits the current record.
665
- const rollupCredited = Boolean(rec && sameRegistrationEpoch(payout, rec));
666
- if (rollupCredited) {
667
- const roll = rec.rollup || emptyRollup();
668
- state.providerRegistry.set(rec.provider_id, {
669
- ...rec,
670
- rollup: { ...roll, paid_out_minor: (Number(roll.paid_out_minor) || 0) + payout.amount_minor },
671
- updated_at_iso: nowIso()
672
- });
673
- }
674
- return { ok: true, payout, rollup_credited: rollupCredited, payout_state: rec ? computeProviderPayoutState(state, payout.provider_id) : null };
675
- }
676
-
677
- // Cancel a pending payout — frees its reserved amount back into available. Never a settled one.
678
- function cancelProviderPayout(state, payoutId) {
679
- ensureProviderCollections(state);
680
- const payout = getProviderPayout(state, payoutId);
681
- if (!payout) return { ok: false, reason: "payout_not_found" };
682
- if (payout.status === "cancelled") return { ok: true, payout, idempotent: true };
683
- if (payout.status !== "pending") return { ok: false, reason: "payout_not_pending", status: payout.status };
684
- if (payoutInFlight(payout)) {
685
- return {
686
- ok: false,
687
- reason: "payout_in_flight",
688
- status: payout.status,
689
- dispatch_started_at_iso: payout.dispatch_started_at_iso || null,
690
- remediation: "this payout is being sent by the signer right now; wait for the dispatch result (it confirms or stays pending for reconciliation) instead of cancelling"
691
- };
692
- }
693
- payout.status = "cancelled";
694
- payout.cancelled_at_iso = nowIso();
695
- return {
696
- ok: true,
697
- payout,
698
- warning: "cancel only a payout NOT yet sent on-chain — the amount returns to available and can be paid again"
699
- };
700
- }
701
-
702
- // Dispatch bookkeeping for the external-signer flow: "in_flight" while the signer call is
703
- // outstanding (blocks cancel, see payoutInFlight), null once the signer has answered.
704
- function markProviderPayoutDispatch(state, payoutId, dispatchState) {
705
- ensureProviderCollections(state);
706
- const payout = getProviderPayout(state, payoutId);
707
- if (!payout) return { ok: false, reason: "payout_not_found" };
708
- if (dispatchState === "in_flight") {
709
- payout.dispatch_state = "in_flight";
710
- payout.dispatch_started_at_iso = nowIso();
711
- } else {
712
- payout.dispatch_state = null;
713
- }
714
- return { ok: true, payout };
715
- }
716
-
717
- // Last-resort safety net: a payout that was cancelled while (or after) the signer actually sent
718
- // the funds is put back to PENDING with the reported tx and flagged, so the reservation is held
719
- // again and the operator reconciles (confirm with the tx) instead of the ledger re-paying it.
720
- function reinstateCancelledPayoutForReconciliation(state, payoutId, txRef) {
721
- ensureProviderCollections(state);
722
- const payout = getProviderPayout(state, payoutId);
723
- if (!payout) return { ok: false, reason: "payout_not_found" };
724
- if (payout.status !== "cancelled") return { ok: false, reason: "payout_not_cancelled", status: payout.status };
725
- payout.status = "pending";
726
- payout.cancelled_at_iso = null;
727
- payout.dispatch_state = null;
728
- payout.tx_ref = normStr(txRef) || payout.tx_ref || null;
729
- payout.reconcile_required = true;
730
- payout.note = [payout.note, "reinstated_after_cancel_during_dispatch"].filter(Boolean).join("; ");
731
- return { ok: true, payout };
732
- }
733
-
734
- function listProviderPayouts(state, filters = {}) {
735
- ensureProviderCollections(state);
736
- let items = state.providerPayouts.slice();
737
- const providerId = normStr(filters && filters.provider_id);
738
- const status = normStr(filters && filters.status);
739
- if (providerId) items = items.filter((p) => p.provider_id === providerId);
740
- if (status) items = items.filter((p) => p.status === status);
741
- if (hasEpochFilter(filters)) items = items.filter((p) => sameRegistrationEpoch(p, { registration_epoch: filters.registration_epoch }));
742
- return items;
743
- }
744
-
745
- // Authenticate a provider acting on its OWN record using the owner_secret minted at
746
- // registration (constant-time compare). Returns the record on match, or a reason code. This is
747
- // the provider-SIDE auth (self-service), distinct from the operator bearer: it lets a
748
- // self-registered provider read its own earnings without granting any operator capability.
749
- function authenticateProviderOwner(state, providerId, ownerSecret) {
750
- ensureProviderCollections(state);
751
- const rec = state.providerRegistry.get(normStr(providerId));
752
- if (!rec) return { ok: false, reason: "provider_not_found" };
753
- if (!timingSafeStrEqual(normStr(ownerSecret), rec.owner_secret)) {
754
- return { ok: false, reason: "provider_owner_mismatch" };
755
- }
756
- return { ok: true, provider: rec };
757
- }
758
-
759
- // Read-only self-service earnings view for a provider: its public profile, computed payout state
760
- // (accrued / paid / pending / available / owed), its settlement ledger rows, and its payout
761
- // history. Composes existing read helpers only — never mutates state, and never leaks auth_token
762
- // or owner_secret (public view + the provider's own ledger rows). Caller must authenticate first.
763
- function buildProviderEarningsView(state, providerId) {
764
- ensureProviderCollections(state);
765
- const rec = state.providerRegistry.get(normStr(providerId));
766
- if (!rec) return null;
767
- // Scoped to THIS registration's epoch: a re-registered id never reads a previous owner's rows.
768
- const epoch = rec.registration_epoch || null;
769
- return {
770
- provider: buildProviderPublicView(rec),
771
- payout_state: computeProviderPayoutState(state, rec.provider_id),
772
- settlements: listProviderSettlements(state, { provider_id: rec.provider_id, registration_epoch: epoch }),
773
- payouts: listProviderPayouts(state, { provider_id: rec.provider_id, registration_epoch: epoch })
774
- };
775
- }
776
-
777
- function summarizeProviderNetwork(state) {
778
- ensureProviderCollections(state);
779
- const providers = Array.from(state.providerRegistry.values());
780
- const byStatus = { pending: 0, active: 0, suspended: 0 };
781
- const totals = { gross_minor: 0, provider_share_minor: 0, platform_take_minor: 0, paid_out_minor: 0, owed_minor: 0, routed_calls: 0, settled_calls: 0 };
782
- for (const p of providers) {
783
- byStatus[p.status] = (byStatus[p.status] || 0) + 1;
784
- const share = rollupField(p, "provider_share_minor");
785
- const paid = rollupField(p, "paid_out_minor");
786
- totals.gross_minor += rollupField(p, "gross_minor");
787
- totals.provider_share_minor += share;
788
- totals.platform_take_minor += rollupField(p, "platform_take_minor");
789
- totals.paid_out_minor += paid;
790
- totals.owed_minor += Math.max(0, share - paid);
791
- totals.routed_calls += rollupField(p, "routed_calls");
792
- totals.settled_calls += rollupField(p, "settled_calls");
793
- }
794
- const nowMs = Date.now();
795
- const health = { routable: 0, unhealthy: 0 };
796
- for (const p of providers) {
797
- if (p.status !== "active") continue;
798
- if (healthRoutingState(p.health, nowMs).routable) health.routable += 1; else health.unhealthy += 1;
799
- }
800
- const pendingPayouts = state.providerPayouts.filter((p) => p.status === "pending");
801
- return {
802
- ok: true,
803
- provider_count: providers.length,
804
- by_status: byStatus,
805
- health,
806
- totals,
807
- settlement_ledger_entries: state.providerSettlementLedger.length,
808
- payouts: {
809
- total: state.providerPayouts.length,
810
- pending: pendingPayouts.length,
811
- pending_minor: pendingPayouts.reduce((sum, p) => sum + (Number(p.amount_minor) || 0), 0)
812
- }
813
- };
814
- }
815
-
816
- module.exports = {
817
- ensureProviderCollections,
818
- registerProvider,
819
- setProviderStatus,
820
- getProvider,
821
- removeProvider,
822
- listProviders,
823
- buildProviderPublicView,
824
- authenticateProviderOwner,
825
- buildProviderEarningsView,
826
- resolveRegisteredTarget,
827
- resolveRegisteredTargets,
828
- hasActiveMatchingProvider,
829
- recordProviderHealth,
830
- recordProviderRouting,
831
- recordProviderSettlement,
832
- listProviderSettlements,
833
- summarizeProviderNetwork,
834
- computeProviderPayoutState,
835
- createProviderPayout,
836
- getProviderPayout,
837
- confirmProviderPayout,
838
- cancelProviderPayout,
839
- markProviderPayoutDispatch,
840
- reinstateCancelledPayoutForReconciliation,
841
- listProviderPayouts
842
- };
1
+ "use strict";
2
+
3
+ // Open execution-provider network (dynamic, state-backed) — the keystone that turns the
4
+ // single-tenant env-pinned execution targets (external_execution_runtime.js) into a
5
+ // two-sided network any provider can plug into: providers self-register their endpoint +
6
+ // the capabilities they serve + their revenue-share; the runtime routes matching paid calls
7
+ // to ACTIVE providers; settlement splits the money (provider cut vs platform take) and
8
+ // records a per-provider ledger.
9
+ //
10
+ // Safe-by-default: a newly registered provider lands "pending" and is NOT routable until an
11
+ // operator activates it, and a re-registration that changes the endpoint reverts an active
12
+ // provider to pending (so a leaked registration bearer cannot silently redirect live paid
13
+ // traffic). With an empty registry every function here is inert — zero behavior change.
14
+
15
+ const crypto = require("crypto");
16
+ const { clampTargetTimeoutMs, checkEndpointUrlShape } = require("./external_execution_policy");
17
+
18
+ function normStr(value, fallback = "") {
19
+ const s = typeof value === "string" ? value.trim() : "";
20
+ return s || fallback;
21
+ }
22
+ function ensureObject(value) {
23
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
24
+ }
25
+ function normStrArray(value) {
26
+ return Array.isArray(value) ? [...new Set(value.map((v) => normStr(v)).filter(Boolean))] : [];
27
+ }
28
+ function nowIso() {
29
+ return new Date().toISOString();
30
+ }
31
+ function emptyRollup() {
32
+ return { routed_calls: 0, settled_calls: 0, gross_minor: 0, provider_share_minor: 0, platform_take_minor: 0, paid_out_minor: 0 };
33
+ }
34
+
35
+ function emptyHealth() {
36
+ return { status: "healthy", consecutive_failures: 0, last_success_at_iso: null, last_failure_at_iso: null, unhealthy_until_iso: null };
37
+ }
38
+
39
+ function emptyReputation() {
40
+ return { success_count: 0, failure_count: 0 };
41
+ }
42
+
43
+ // Smoothed reputation score in (0,1] for ROUTING order. Uses an optimistic prior (+2 pseudo-
44
+ // successes): a no-history provider scores 1.0 (OPTIMISTIC cold-start — it still gets a fair
45
+ // first shot), and a single early transient failure scores ~0.67 (recoverable), NEVER 0.0 — so
46
+ // a provider is not permanently doomed/starved by one bad call. Bad providers still sink over time.
47
+ // Optional recency decay for the ROUTING score only (never money, never health exclusion). A
48
+ // factor in (0,1) fades older observations on each update so recent performance dominates — a
49
+ // provider that regressed sinks, one that recovered rises. Default 1.0 = NO decay, i.e. exactly
50
+ // the lifetime-count behavior. Routing-only: raw lifetime counts stay intact for display.
51
+ function reputationDecayFactor() {
52
+ const n = Number(process.env.XYTARA_PROVIDER_REPUTATION_DECAY);
53
+ return Number.isFinite(n) && n > 0 && n < 1 ? n : 1;
54
+ }
55
+
56
+ function reputationScore(rec) {
57
+ const rep = (rec && rec.reputation) || emptyReputation();
58
+ // Prefer the recency-weighted pseudo-counts when present; fall back to raw lifetime counts for a
59
+ // no-history or pre-decay provider. With the default factor 1.0 the two are identical. Same
60
+ // optimistic +2 prior either way (a new provider still scores 1.0 and gets a fair first shot).
61
+ const success = Number.isFinite(Number(rep.decayed_success)) ? Number(rep.decayed_success) : (Number(rep.success_count) || 0);
62
+ const failure = Number.isFinite(Number(rep.decayed_failure)) ? Number(rep.decayed_failure) : (Number(rep.failure_count) || 0);
63
+ return (success + 2) / (success + failure + 2);
64
+ }
65
+
66
+ function reputationView(rec) {
67
+ const rep = { ...emptyReputation(), ...((rec && rec.reputation) || {}) };
68
+ const success = Number(rep.success_count) || 0;
69
+ const failure = Number(rep.failure_count) || 0;
70
+ const total = success + failure;
71
+ return {
72
+ success_count: success,
73
+ failure_count: failure,
74
+ total_calls: total,
75
+ // RAW observed success rate for display; the smoothed reputationScore above is routing-only.
76
+ success_rate: total === 0 ? null : Number((success / total).toFixed(4))
77
+ };
78
+ }
79
+
80
+ function unhealthyThreshold() {
81
+ const n = Number(process.env.XYTARA_PROVIDER_UNHEALTHY_THRESHOLD);
82
+ return Number.isInteger(n) && n >= 1 ? n : 3;
83
+ }
84
+ function unhealthyCooldownMs() {
85
+ const n = Number(process.env.XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS);
86
+ // Clamp to <= 24h so Date.now()+cooldown can never overflow a valid Date (which would throw
87
+ // in .toISOString() and silently drop the unhealthy transition, leaving a bad provider live).
88
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, 86400000) : 60000;
89
+ }
90
+ function failoverMax() {
91
+ const n = Number(process.env.XYTARA_PROVIDER_FAILOVER_MAX);
92
+ return Number.isInteger(n) && n >= 1 ? n : 3;
93
+ }
94
+
95
+ // A provider is routable w.r.t. health when it is healthy, OR it was marked unhealthy but the
96
+ // cooldown has elapsed (half-open: give it one more chance). Healthy is preferred over
97
+ // half-open at selection time. Returns null for routable, or "unhealthy_cooldown" when skipped.
98
+ function healthRoutingState(health, nowMs) {
99
+ const h = health || emptyHealth();
100
+ if (h.status !== "unhealthy") return { routable: true, half_open: false };
101
+ const until = h.unhealthy_until_iso ? Date.parse(h.unhealthy_until_iso) : 0;
102
+ // null -> 0 -> elapsed; a garbage/unparseable timestamp -> NaN -> treat as elapsed (recover,
103
+ // never strand a provider permanently excluded). Only a valid FUTURE timestamp excludes it.
104
+ if (!Number.isFinite(until) || nowMs >= until) return { routable: true, half_open: true };
105
+ return { routable: false, half_open: false };
106
+ }
107
+
108
+ const PAYOUT_STATUSES = new Set(["pending", "settled", "cancelled"]);
109
+
110
+ const PROVIDER_STATUSES = new Set(["pending", "active", "suspended"]);
111
+
112
+ function timingSafeStrEqual(a, b) {
113
+ const x = Buffer.from(String(a || ""), "utf8");
114
+ const y = Buffer.from(String(b || ""), "utf8");
115
+ if (x.length !== y.length || x.length === 0) return false;
116
+ try { return crypto.timingSafeEqual(x, y); } catch (_) { return false; }
117
+ }
118
+
119
+ function maxRevenueShareBps() {
120
+ const raw = Number(process.env.XYTARA_PROVIDER_MAX_REVENUE_SHARE_BPS);
121
+ return Number.isInteger(raw) && raw >= 0 && raw <= 10000 ? raw : 10000;
122
+ }
123
+
124
+ // Fields whose change on an ACTIVE provider must force re-approval (back to pending): the
125
+ // endpoint it is called at, the capabilities it serves (which calls route to it), its
126
+ // revenue split, and the token used to call it. Metadata (label/pricing/timeout) is exempt.
127
+ function routingMaterialChanged(existing, next) {
128
+ // JSON-encode the sorted, deduped members (not a delimiter-join) so a member containing
129
+ // the separator cannot collide two different sets onto the same string.
130
+ const sameSet = (a, b) => {
131
+ const norm = (arr) => JSON.stringify([...new Set((arr || []).map(String))].sort());
132
+ return norm(a) === norm(b);
133
+ };
134
+ return existing.endpoint_url !== next.endpoint_url
135
+ || !sameSet(existing.task_refs, next.task_refs)
136
+ || !sameSet(existing.tool_names, next.tool_names)
137
+ || !sameSet(existing.integration_ids, next.integration_ids)
138
+ || existing.revenue_share_bps !== next.revenue_share_bps
139
+ || (existing.auth_token || null) !== (next.auth_token || null);
140
+ }
141
+
142
+ function ensureProviderCollections(state) {
143
+ if (!state || typeof state !== "object") return;
144
+ if (!(state.providerRegistry instanceof Map)) state.providerRegistry = new Map();
145
+ if (!Array.isArray(state.providerSettlementLedger)) state.providerSettlementLedger = [];
146
+ if (!Array.isArray(state.providerPayouts)) state.providerPayouts = [];
147
+ }
148
+
149
+ function rollupField(rec, field) {
150
+ return rec && rec.rollup && Number.isFinite(Number(rec.rollup[field])) ? Number(rec.rollup[field]) : 0;
151
+ }
152
+
153
+ // Ledger rows and payouts belong to a REGISTRATION of a provider_id, not to the id itself: a
154
+ // removed id that someone else re-registers must never see, inherit, or be credited by the
155
+ // previous owner's rows. Legacy rows/records without an epoch match each other (null === null).
156
+ function sameRegistrationEpoch(row, rec) {
157
+ return ((row && row.registration_epoch) || null) === ((rec && rec.registration_epoch) || null);
158
+ }
159
+
160
+ // A payout mid-dispatch to the signer must not be cancellable: the signer may already have sent
161
+ // the funds, and freeing the reservation would let a second payout re-send them. The guard
162
+ // expires (process died during dispatch) so an operator can still reconcile a stuck record.
163
+ const IN_FLIGHT_CANCEL_GUARD_MS = 5 * 60 * 1000;
164
+ function payoutInFlight(payout) {
165
+ if (!payout || payout.dispatch_state !== "in_flight") return false;
166
+ const started = Date.parse(payout.dispatch_started_at_iso || "");
167
+ return !Number.isFinite(started) || Date.now() - started < IN_FLIGHT_CANCEL_GUARD_MS;
168
+ }
169
+
170
+ // What a provider is owed, accounting for already-settled payouts AND outstanding pending ones
171
+ // so two concurrent pending payouts can never over-draw the accrued share.
172
+ // accrued = cumulative provider_share credited by settlements
173
+ // paid_out = cumulative confirmed (settled) payouts
174
+ // pending = sum of not-yet-confirmed payouts
175
+ // available = accrued - paid_out - pending (the max a NEW payout may claim)
176
+ // owed = accrued - paid_out (total still outstanding, incl. pending)
177
+ function computeProviderPayoutState(state, providerId) {
178
+ ensureProviderCollections(state);
179
+ const rec = state.providerRegistry.get(normStr(providerId));
180
+ if (!rec) return null;
181
+ const accrued = rollupField(rec, "provider_share_minor");
182
+ const paidOut = rollupField(rec, "paid_out_minor");
183
+ const pending = state.providerPayouts
184
+ .filter((p) => p.provider_id === rec.provider_id && sameRegistrationEpoch(p, rec) && p.status === "pending")
185
+ .reduce((sum, p) => sum + (Number(p.amount_minor) || 0), 0);
186
+ return {
187
+ provider_id: rec.provider_id,
188
+ payout_address: rec.payout_address || null,
189
+ payout_network: rec.payout_network || null,
190
+ payout_asset: rec.payout_asset || null,
191
+ currency: "USD_CENTS",
192
+ accrued_minor: accrued,
193
+ paid_out_minor: paidOut,
194
+ pending_minor: pending,
195
+ available_minor: Math.max(0, accrued - paidOut - pending),
196
+ owed_minor: Math.max(0, accrued - paidOut)
197
+ };
198
+ }
199
+
200
+ // Never leaks auth_token — callers that need to CALL the provider read the stored record
201
+ // directly (resolveRegisteredTarget); everything buyer/operator-facing uses this view.
202
+ function buildProviderPublicView(rec) {
203
+ if (!rec) return null;
204
+ return {
205
+ provider_id: rec.provider_id,
206
+ label: rec.label,
207
+ endpoint_url: rec.endpoint_url,
208
+ task_refs: rec.task_refs,
209
+ tool_names: rec.tool_names,
210
+ integration_ids: rec.integration_ids,
211
+ revenue_share_bps: rec.revenue_share_bps,
212
+ pricing: rec.pricing,
213
+ status: rec.status,
214
+ timeout_ms: rec.timeout_ms,
215
+ auth_bound: Boolean(rec.auth_token),
216
+ payout_bound: Boolean(rec.payout_address),
217
+ payout_network: rec.payout_network || null,
218
+ payout_asset: rec.payout_asset || null,
219
+ registered_at_iso: rec.registered_at_iso,
220
+ activated_at_iso: rec.activated_at_iso,
221
+ updated_at_iso: rec.updated_at_iso,
222
+ rollup: { ...emptyRollup(), ...(rec.rollup || {}) },
223
+ health: { ...emptyHealth(), ...(rec.health || {}) },
224
+ reputation: reputationView(rec)
225
+ };
226
+ }
227
+
228
+ // POST register (or idempotent update keyed on provider_id).
229
+ function registerProvider(state, input) {
230
+ ensureProviderCollections(state);
231
+ const b = ensureObject(input);
232
+ const endpointUrl = normStr(b.endpoint_url);
233
+ if (!endpointUrl || !/^https?:\/\//i.test(endpointUrl)) {
234
+ return { ok: false, reason: "endpoint_url_required" };
235
+ }
236
+ // Endpoint policy (strict on hosted deploys): https only, no loopback/private/link-local/metadata
237
+ // hosts, no embedded credentials — the runtime POSTs to this URL, so it must not be usable as a
238
+ // proxy into the runtime's own network. Re-checked (with DNS) at dispatch time.
239
+ const endpointPolicy = checkEndpointUrlShape(endpointUrl);
240
+ if (!endpointPolicy.ok) {
241
+ return { ok: false, reason: endpointPolicy.reason, detail: endpointPolicy.detail || null, endpoint_policy: endpointPolicy.policy };
242
+ }
243
+ const taskRefs = normStrArray(b.task_refs);
244
+ const toolNames = normStrArray(b.tool_names);
245
+ const integrationIds = normStrArray(b.integration_ids);
246
+ if (taskRefs.length + toolNames.length + integrationIds.length < 1) {
247
+ return { ok: false, reason: "capability_selector_required" };
248
+ }
249
+ const shareBps = Number(b.revenue_share_bps);
250
+ if (!Number.isInteger(shareBps) || shareBps < 0 || shareBps > 10000) {
251
+ return { ok: false, reason: "revenue_share_bps_invalid" };
252
+ }
253
+ if (shareBps > maxRevenueShareBps()) {
254
+ return { ok: false, reason: "revenue_share_bps_exceeds_max", max_revenue_share_bps: maxRevenueShareBps() };
255
+ }
256
+ const providerId = normStr(b.provider_id) || `provider_${crypto.randomBytes(6).toString("hex")}`;
257
+ const existing = state.providerRegistry.get(providerId) || null;
258
+
259
+ // Per-provider ownership: a new provider_id mints an owner_secret (returned ONCE); any
260
+ // re-registration of an existing provider_id MUST present that secret. This stops one
261
+ // holder of the shared registration bearer from mutating, re-pointing, or DoS-ing another
262
+ // provider's record.
263
+ if (existing && !timingSafeStrEqual(normStr(b.owner_secret), existing.owner_secret)) {
264
+ return { ok: false, reason: "provider_owner_mismatch" };
265
+ }
266
+ const ownerSecret = existing ? existing.owner_secret : `prvown_${crypto.randomBytes(24).toString("hex")}`;
267
+ const authToken = normStr(b.auth_token) || (existing && existing.auth_token) || null;
268
+
269
+ // Payout destination (where this provider's revenue-share is paid). Owner-protected like
270
+ // the rest of the record; NOT routing-material (changing it does not affect execution, so
271
+ // it does not revert an active provider to pending). An eip155 address is shape-checked.
272
+ const payoutNetwork = normStr(b.payout_network) || (existing && existing.payout_network) || "eip155:8453";
273
+ const payoutAsset = normStr(b.payout_asset) || (existing && existing.payout_asset) || "USDC";
274
+ const payoutAddress = normStr(b.payout_address) || (existing && existing.payout_address) || null;
275
+ if (payoutAddress && /^eip155/i.test(payoutNetwork) && !/^0x[0-9a-fA-F]{40}$/.test(payoutAddress)) {
276
+ return { ok: false, reason: "payout_address_invalid" };
277
+ }
278
+
279
+ const nextRouting = {
280
+ endpoint_url: endpointUrl,
281
+ task_refs: taskRefs,
282
+ tool_names: toolNames,
283
+ integration_ids: integrationIds,
284
+ revenue_share_bps: shareBps,
285
+ auth_token: authToken
286
+ };
287
+ // New -> pending. Re-registration that changes ANY routing-material field (endpoint, served
288
+ // capabilities, revenue split, or call token) reverts an ACTIVE provider to pending, so an
289
+ // expanded or altered capability can never receive paid traffic without operator re-approval.
290
+ let status = existing ? existing.status : "pending";
291
+ if (existing && existing.status === "active" && routingMaterialChanged(existing, nextRouting)) {
292
+ status = "pending";
293
+ }
294
+
295
+ const record = {
296
+ provider_id: providerId,
297
+ label: normStr(b.label, providerId),
298
+ endpoint_url: endpointUrl,
299
+ task_refs: taskRefs,
300
+ tool_names: toolNames,
301
+ integration_ids: integrationIds,
302
+ revenue_share_bps: shareBps,
303
+ pricing: ensureObject(b.pricing),
304
+ payout_address: payoutAddress,
305
+ payout_network: payoutNetwork,
306
+ payout_asset: payoutAsset,
307
+ auth_token: authToken,
308
+ owner_secret: ownerSecret,
309
+ // Minted once per registration; every ledger row / payout is stamped with it (see
310
+ // sameRegistrationEpoch). A re-registration of an existing record keeps its epoch.
311
+ registration_epoch: existing ? (existing.registration_epoch || null) : `reg_${crypto.randomBytes(6).toString("hex")}`,
312
+ // Capped (30s): a longer call could not settle inside the x402 authorization window.
313
+ timeout_ms: clampTargetTimeoutMs(b.timeout_ms),
314
+ status,
315
+ registered_by: normStr(b.registered_by) || (existing && existing.registered_by) || null,
316
+ registered_at_iso: existing ? existing.registered_at_iso : nowIso(),
317
+ activated_at_iso: status === "active" ? (existing && existing.activated_at_iso) || null : (existing && existing.activated_at_iso) || null,
318
+ updated_at_iso: nowIso(),
319
+ rollup: existing ? existing.rollup : emptyRollup(),
320
+ health: existing ? (existing.health || emptyHealth()) : emptyHealth(),
321
+ reputation: existing ? (existing.reputation || emptyReputation()) : emptyReputation()
322
+ };
323
+ state.providerRegistry.set(providerId, record);
324
+ const result = { ok: true, provider_id: providerId, status, provider: buildProviderPublicView(record) };
325
+ if (!existing) result.owner_secret = ownerSecret; // returned exactly once, at first registration
326
+ return result;
327
+ }
328
+
329
+ // Operator activate / suspend / re-pend.
330
+ function setProviderStatus(state, providerId, status) {
331
+ ensureProviderCollections(state);
332
+ const nextStatus = normStr(status);
333
+ if (!PROVIDER_STATUSES.has(nextStatus)) return { ok: false, reason: "invalid_status" };
334
+ const rec = state.providerRegistry.get(normStr(providerId));
335
+ if (!rec) return { ok: false, reason: "provider_not_found" };
336
+ const next = {
337
+ ...rec,
338
+ status: nextStatus,
339
+ activated_at_iso: nextStatus === "active" ? rec.activated_at_iso || nowIso() : rec.activated_at_iso,
340
+ updated_at_iso: nowIso()
341
+ };
342
+ state.providerRegistry.set(rec.provider_id, next);
343
+ return { ok: true, provider: buildProviderPublicView(next) };
344
+ }
345
+
346
+ function getProvider(state, providerId) {
347
+ ensureProviderCollections(state);
348
+ return state.providerRegistry.get(normStr(providerId)) || null;
349
+ }
350
+
351
+ // Operator removes a provider from the registry (e.g. to purge suspended/spam records and free
352
+ // the pending pool). Refuses when the provider still has an unpaid owed balance unless `force`,
353
+ // so a provider with earnings can't be silently dropped.
354
+ function removeProvider(state, providerId, options = {}) {
355
+ ensureProviderCollections(state);
356
+ const id = normStr(providerId);
357
+ const rec = state.providerRegistry.get(id);
358
+ if (!rec) return { ok: false, reason: "provider_not_found" };
359
+ const owed = Math.max(0, rollupField(rec, "provider_share_minor") - rollupField(rec, "paid_out_minor"));
360
+ if (owed > 0 && !(options && options.force)) {
361
+ return { ok: false, reason: "provider_has_unpaid_balance", owed_minor: owed };
362
+ }
363
+ // Cancel this registration's pending payouts so they can never be confirmed against (or reserve
364
+ // balance of) a later registration of the same id. A payout mid-dispatch may already have been
365
+ // sent, so it is left pending for manual reconciliation. Ledger rows are retained as the
366
+ // operator's audit trail; they are epoch-scoped so a new registration never sees them.
367
+ const cancelledPendingPayouts = [];
368
+ const inFlightPayouts = [];
369
+ for (const payout of state.providerPayouts) {
370
+ if (payout.provider_id !== id || payout.status !== "pending" || !sameRegistrationEpoch(payout, rec)) continue;
371
+ if (payoutInFlight(payout)) {
372
+ inFlightPayouts.push(payout.payout_id);
373
+ continue;
374
+ }
375
+ payout.status = "cancelled";
376
+ payout.cancelled_at_iso = nowIso();
377
+ payout.note = [payout.note, "provider_removed"].filter(Boolean).join("; ");
378
+ cancelledPendingPayouts.push(payout.payout_id);
379
+ }
380
+ state.providerRegistry.delete(id);
381
+ return {
382
+ ok: true,
383
+ removed: id,
384
+ forced: Boolean(options && options.force),
385
+ owed_minor_at_removal: owed,
386
+ cancelled_pending_payouts: cancelledPendingPayouts,
387
+ in_flight_payouts_left_pending: inFlightPayouts
388
+ };
389
+ }
390
+
391
+ function listProviders(state, filters = {}) {
392
+ ensureProviderCollections(state);
393
+ let items = Array.from(state.providerRegistry.values());
394
+ const status = normStr(filters && filters.status);
395
+ if (status) items = items.filter((p) => p.status === status);
396
+ return items;
397
+ }
398
+
399
+ // Resolve an ACTIVE registered provider for a call. Mirrors the env-target selectors
400
+ // (task_ref / tool_name / integration_id) but a provider matches if the call hits ANY of
401
+ // the capabilities it declared it serves. Returns a target shaped for executeExternalTarget.
402
+ function providerMatchesCall(p, taskRefs, toolName, integrationIds) {
403
+ if (p.status !== "active") return false;
404
+ if (p.task_refs.length && p.task_refs.some((r) => taskRefs.includes(r))) return true;
405
+ if (p.tool_names.length && toolName && p.tool_names.includes(toolName)) return true;
406
+ if (p.integration_ids.length && p.integration_ids.some((id) => integrationIds.has(id))) return true;
407
+ return false;
408
+ }
409
+
410
+ function targetFromProvider(p) {
411
+ return {
412
+ target_id: p.provider_id,
413
+ endpoint_url: p.endpoint_url,
414
+ task_ref: null,
415
+ tool_name: null,
416
+ integration_id: null,
417
+ provider: p.provider_id,
418
+ auth_token: p.auth_token,
419
+ timeout_ms: p.timeout_ms,
420
+ enabled: true,
421
+ registered_provider_id: p.provider_id
422
+ };
423
+ }
424
+
425
+ // All ACTIVE + health-routable providers matching the call, ordered healthy-first then
426
+ // half-open (recovered past their cooldown), capped at the failover max — so a caller can try
427
+ // them in sequence and fail over when one is down. Currently-unhealthy (in-cooldown) providers
428
+ // are excluded.
429
+ function extractCallSelectors(payload, quote) {
430
+ const body = ensureObject(payload && payload.body);
431
+ const taskRefs = Array.isArray(payload && payload.tasks) && payload.tasks.length > 0
432
+ ? payload.tasks.map((t) => normStr(t && t.task_ref)).filter(Boolean)
433
+ : [normStr(payload && payload.task_ref)].filter(Boolean);
434
+ const toolName = normStr(body.tool_name) || null;
435
+ const integrationIds = new Set(
436
+ []
437
+ .concat(normStr(payload && payload.integration_id) ? [normStr(payload && payload.integration_id)] : [])
438
+ .concat(Array.isArray(payload && payload.integration_ids) ? payload.integration_ids.map((v) => normStr(v)).filter(Boolean) : [])
439
+ .concat(Array.isArray(quote && quote.integration_ids) ? quote.integration_ids.map((v) => normStr(v)).filter(Boolean) : [])
440
+ );
441
+ return { taskRefs, toolName, integrationIds };
442
+ }
443
+
444
+ // Whether ANY active provider matches the call, regardless of health. Lets the caller tell
445
+ // "no provider serves this capability" (fall through to local handling) apart from "providers
446
+ // serve it but are all currently unhealthy" (fail — don't charge for undelivered work).
447
+ function hasActiveMatchingProvider(state, payload, quote) {
448
+ ensureProviderCollections(state);
449
+ if (state.providerRegistry.size < 1) return false;
450
+ const { taskRefs, toolName, integrationIds } = extractCallSelectors(payload, quote);
451
+ for (const p of state.providerRegistry.values()) {
452
+ if (providerMatchesCall(p, taskRefs, toolName, integrationIds)) return true;
453
+ }
454
+ return false;
455
+ }
456
+
457
+ function resolveRegisteredTargets(state, payload, quote) {
458
+ ensureProviderCollections(state);
459
+ if (state.providerRegistry.size < 1) return [];
460
+ const { taskRefs, toolName, integrationIds } = extractCallSelectors(payload, quote);
461
+ const nowMs = Date.now();
462
+ const healthy = [];
463
+ const halfOpen = [];
464
+ for (const p of state.providerRegistry.values()) {
465
+ if (!providerMatchesCall(p, taskRefs, toolName, integrationIds)) continue;
466
+ const hs = healthRoutingState(p.health, nowMs);
467
+ if (!hs.routable) continue;
468
+ (hs.half_open ? halfOpen : healthy).push(p);
469
+ }
470
+ // Within the healthy tier, prefer higher lifetime reputation (stable sort keeps registration
471
+ // order for ties; a no-history provider scores 1.0 optimistically so it still gets routed and
472
+ // can build a record). Half-open (recovering) providers always come after healthy ones.
473
+ healthy.sort((a, b) => reputationScore(b) - reputationScore(a));
474
+ return healthy.concat(halfOpen).map(targetFromProvider).slice(0, failoverMax());
475
+ }
476
+
477
+ function resolveRegisteredTarget(state, payload, quote) {
478
+ const targets = resolveRegisteredTargets(state, payload, quote);
479
+ return targets.length > 0 ? targets[0] : null;
480
+ }
481
+
482
+ // Update a provider's health after a routed call. Success resets to healthy; failure increments
483
+ // consecutive_failures and, on crossing the threshold, marks it unhealthy with a cooldown
484
+ // (excluded from routing until the cooldown elapses, then half-open for one retry).
485
+ function recordProviderHealth(state, providerId, result = {}) {
486
+ ensureProviderCollections(state);
487
+ const rec = state.providerRegistry.get(normStr(providerId));
488
+ if (!rec) return { ok: false, reason: "provider_not_found" };
489
+ const h = rec.health || emptyHealth();
490
+ const stamp = nowIso();
491
+ let next;
492
+ if (result && result.ok) {
493
+ next = { status: "healthy", consecutive_failures: 0, last_success_at_iso: stamp, last_failure_at_iso: h.last_failure_at_iso || null, unhealthy_until_iso: null };
494
+ } else {
495
+ const failures = (Number(h.consecutive_failures) || 0) + 1;
496
+ const nowUnhealthy = failures >= unhealthyThreshold();
497
+ next = {
498
+ status: nowUnhealthy ? "unhealthy" : h.status,
499
+ consecutive_failures: failures,
500
+ last_success_at_iso: h.last_success_at_iso || null,
501
+ last_failure_at_iso: stamp,
502
+ unhealthy_until_iso: nowUnhealthy ? new Date(Date.now() + unhealthyCooldownMs()).toISOString() : (h.unhealthy_until_iso || null)
503
+ };
504
+ }
505
+ const rep = rec.reputation || emptyReputation();
506
+ const ok = Boolean(result && result.ok);
507
+ // Raw lifetime counts (for display / success_rate) — unchanged integer-increment semantics.
508
+ const rawSuccess = (Number(rep.success_count) || 0) + (ok ? 1 : 0);
509
+ const rawFailure = (Number(rep.failure_count) || 0) + (ok ? 0 : 1);
510
+ // Recency-weighted pseudo-counts (routing order). Seeded from the raw counts the first time, then
511
+ // decayed each update. With the default factor 1.0 these stay equal to the raw counts exactly.
512
+ const decay = reputationDecayFactor();
513
+ const baseSuccess = Number.isFinite(Number(rep.decayed_success)) ? Number(rep.decayed_success) : (Number(rep.success_count) || 0);
514
+ const baseFailure = Number.isFinite(Number(rep.decayed_failure)) ? Number(rep.decayed_failure) : (Number(rep.failure_count) || 0);
515
+ const nextRep = {
516
+ success_count: rawSuccess,
517
+ failure_count: rawFailure,
518
+ decayed_success: baseSuccess * decay + (ok ? 1 : 0),
519
+ decayed_failure: baseFailure * decay + (ok ? 0 : 1)
520
+ };
521
+ state.providerRegistry.set(rec.provider_id, { ...rec, health: next, reputation: nextRep, updated_at_iso: stamp });
522
+ return { ok: true, health: next, reputation: nextRep };
523
+ }
524
+
525
+ // Count a routed dispatch (whether or not it later settles).
526
+ function recordProviderRouting(state, providerId) {
527
+ ensureProviderCollections(state);
528
+ const rec = state.providerRegistry.get(normStr(providerId));
529
+ if (!rec) return { ok: false, reason: "provider_not_found" };
530
+ const roll = rec.rollup || emptyRollup();
531
+ state.providerRegistry.set(rec.provider_id, {
532
+ ...rec,
533
+ rollup: { ...roll, routed_calls: (roll.routed_calls || 0) + 1 },
534
+ updated_at_iso: nowIso()
535
+ });
536
+ return { ok: true };
537
+ }
538
+
539
+ // Split a settled amount for a provider and record a ledger entry. provider_share is floored
540
+ // (platform keeps the rounding remainder); amounts are integer minor units.
541
+ function recordProviderSettlement(state, input) {
542
+ ensureProviderCollections(state);
543
+ const b = ensureObject(input);
544
+ const rec = state.providerRegistry.get(normStr(b.provider_id));
545
+ if (!rec) return { ok: false, reason: "provider_not_found" };
546
+ const gross = Math.max(0, Math.round(Number(b.amount_minor) || 0));
547
+ const providerShare = Math.floor((gross * rec.revenue_share_bps) / 10000);
548
+ const platformTake = gross - providerShare;
549
+ const optionalMinor = (value) => (value !== undefined && value !== null && Number.isFinite(Number(value)) ? Math.round(Number(value)) : null);
550
+ const entry = {
551
+ settlement_entry_id: `provset_${crypto.randomBytes(6).toString("hex")}`,
552
+ provider_id: rec.provider_id,
553
+ registration_epoch: rec.registration_epoch || null,
554
+ transaction_id: normStr(b.transaction_id) || null,
555
+ settlement_ref: normStr(b.settlement_ref) || null,
556
+ task_ref: normStr(b.task_ref) || null,
557
+ currency: normStr(b.currency) || null,
558
+ // What the accrual was based on (the caller reconciles quote vs. what actually settled).
559
+ settlement_basis: normStr(b.settlement_basis) || "quote",
560
+ quoted_minor: optionalMinor(b.quoted_minor),
561
+ settled_minor: optionalMinor(b.settled_minor),
562
+ reconciled: b.reconciled === undefined ? true : Boolean(b.reconciled),
563
+ gross_minor: gross,
564
+ revenue_share_bps: rec.revenue_share_bps,
565
+ provider_share_minor: providerShare,
566
+ platform_take_minor: platformTake,
567
+ recorded_at_iso: nowIso()
568
+ };
569
+ state.providerSettlementLedger.push(entry);
570
+ const roll = rec.rollup || emptyRollup();
571
+ state.providerRegistry.set(rec.provider_id, {
572
+ ...rec,
573
+ rollup: {
574
+ ...roll,
575
+ settled_calls: (roll.settled_calls || 0) + 1,
576
+ gross_minor: (roll.gross_minor || 0) + gross,
577
+ provider_share_minor: (roll.provider_share_minor || 0) + providerShare,
578
+ platform_take_minor: (roll.platform_take_minor || 0) + platformTake
579
+ },
580
+ updated_at_iso: nowIso()
581
+ });
582
+ return { ok: true, settlement: entry };
583
+ }
584
+
585
+ // Optional `registration_epoch` filter (pass null to select legacy un-stamped rows) scopes the
586
+ // rows to ONE registration of the id; without it the operator sees the full audit trail.
587
+ function hasEpochFilter(filters) {
588
+ return Boolean(filters) && Object.prototype.hasOwnProperty.call(filters, "registration_epoch");
589
+ }
590
+
591
+ function listProviderSettlements(state, filters = {}) {
592
+ ensureProviderCollections(state);
593
+ let items = state.providerSettlementLedger.slice();
594
+ const providerId = normStr(filters && filters.provider_id);
595
+ if (providerId) items = items.filter((e) => e.provider_id === providerId);
596
+ if (hasEpochFilter(filters)) items = items.filter((e) => sameRegistrationEpoch(e, { registration_epoch: filters.registration_epoch }));
597
+ return items;
598
+ }
599
+
600
+ // Create a payout CLAIM for a provider (operator action). Amount defaults to the full
601
+ // available balance and can NEVER exceed available (accrued - paid - pending), so concurrent
602
+ // pending payouts cannot over-draw. The runtime does NOT move funds here — it records the
603
+ // intent; the operator executes the on-chain transfer and confirms it with the tx hash.
604
+ function createProviderPayout(state, providerId, input = {}) {
605
+ ensureProviderCollections(state);
606
+ const rec = state.providerRegistry.get(normStr(providerId));
607
+ if (!rec) return { ok: false, reason: "provider_not_found" };
608
+ if (!rec.payout_address) return { ok: false, reason: "payout_address_not_set" };
609
+ const payoutState = computeProviderPayoutState(state, rec.provider_id);
610
+ const available = payoutState.available_minor;
611
+ if (available <= 0) return { ok: false, reason: "nothing_payable", available_minor: available };
612
+ const b = ensureObject(input);
613
+ const requested = b.amount_minor !== undefined && b.amount_minor !== null ? Math.round(Number(b.amount_minor)) : available;
614
+ if (!Number.isFinite(requested) || requested <= 0) return { ok: false, reason: "amount_invalid" };
615
+ if (requested > available) return { ok: false, reason: "amount_exceeds_available", available_minor: available };
616
+ const payout = {
617
+ payout_id: `provpay_${crypto.randomBytes(6).toString("hex")}`,
618
+ provider_id: rec.provider_id,
619
+ registration_epoch: rec.registration_epoch || null,
620
+ dispatch_state: null,
621
+ dispatch_started_at_iso: null,
622
+ payout_address: rec.payout_address,
623
+ payout_network: rec.payout_network || null,
624
+ payout_asset: rec.payout_asset || null,
625
+ amount_minor: requested,
626
+ currency: "USD_CENTS",
627
+ status: "pending",
628
+ tx_ref: null,
629
+ created_at_iso: nowIso(),
630
+ settled_at_iso: null,
631
+ cancelled_at_iso: null,
632
+ note: normStr(b.note) || null
633
+ };
634
+ state.providerPayouts.push(payout);
635
+ return { ok: true, payout, payout_state: computeProviderPayoutState(state, rec.provider_id) };
636
+ }
637
+
638
+ function getProviderPayout(state, payoutId) {
639
+ ensureProviderCollections(state);
640
+ return state.providerPayouts.find((p) => p.payout_id === normStr(payoutId)) || null;
641
+ }
642
+
643
+ // Confirm a payout the operator executed on-chain. Idempotent: re-confirming a settled payout
644
+ // returns it without double-crediting paid_out. Only a pending payout credits paid_out_minor.
645
+ function confirmProviderPayout(state, payoutId, input = {}) {
646
+ ensureProviderCollections(state);
647
+ const payout = getProviderPayout(state, payoutId);
648
+ if (!payout) return { ok: false, reason: "payout_not_found" };
649
+ if (payout.status === "settled") return { ok: true, payout, idempotent: true };
650
+ if (payout.status !== "pending") return { ok: false, reason: "payout_not_pending", status: payout.status };
651
+ const txRef = normStr(ensureObject(input).tx_ref);
652
+ if (!txRef) return { ok: false, reason: "tx_ref_required" };
653
+ // A tx_ref may back only ONE settled payout. Otherwise two payouts credit paid_out for a
654
+ // single on-chain transfer, silently under-paying the provider and burning reserved balance.
655
+ if (state.providerPayouts.some((p) => p.status === "settled" && p.tx_ref === txRef && p.payout_id !== payout.payout_id)) {
656
+ return { ok: false, reason: "duplicate_tx_ref" };
657
+ }
658
+ payout.status = "settled";
659
+ payout.tx_ref = txRef;
660
+ payout.settled_at_iso = nowIso();
661
+ payout.dispatch_state = null;
662
+ const rec = state.providerRegistry.get(payout.provider_id);
663
+ // Credit paid_out ONLY on the registration the payout belongs to — a payout from a previous
664
+ // (removed) registration of the same id is recorded but never credits the current record.
665
+ const rollupCredited = Boolean(rec && sameRegistrationEpoch(payout, rec));
666
+ if (rollupCredited) {
667
+ const roll = rec.rollup || emptyRollup();
668
+ state.providerRegistry.set(rec.provider_id, {
669
+ ...rec,
670
+ rollup: { ...roll, paid_out_minor: (Number(roll.paid_out_minor) || 0) + payout.amount_minor },
671
+ updated_at_iso: nowIso()
672
+ });
673
+ }
674
+ return { ok: true, payout, rollup_credited: rollupCredited, payout_state: rec ? computeProviderPayoutState(state, payout.provider_id) : null };
675
+ }
676
+
677
+ // Cancel a pending payout — frees its reserved amount back into available. Never a settled one.
678
+ function cancelProviderPayout(state, payoutId) {
679
+ ensureProviderCollections(state);
680
+ const payout = getProviderPayout(state, payoutId);
681
+ if (!payout) return { ok: false, reason: "payout_not_found" };
682
+ if (payout.status === "cancelled") return { ok: true, payout, idempotent: true };
683
+ if (payout.status !== "pending") return { ok: false, reason: "payout_not_pending", status: payout.status };
684
+ if (payoutInFlight(payout)) {
685
+ return {
686
+ ok: false,
687
+ reason: "payout_in_flight",
688
+ status: payout.status,
689
+ dispatch_started_at_iso: payout.dispatch_started_at_iso || null,
690
+ remediation: "this payout is being sent by the signer right now; wait for the dispatch result (it confirms or stays pending for reconciliation) instead of cancelling"
691
+ };
692
+ }
693
+ payout.status = "cancelled";
694
+ payout.cancelled_at_iso = nowIso();
695
+ return {
696
+ ok: true,
697
+ payout,
698
+ warning: "cancel only a payout NOT yet sent on-chain — the amount returns to available and can be paid again"
699
+ };
700
+ }
701
+
702
+ // Dispatch bookkeeping for the external-signer flow: "in_flight" while the signer call is
703
+ // outstanding (blocks cancel, see payoutInFlight), null once the signer has answered.
704
+ function markProviderPayoutDispatch(state, payoutId, dispatchState) {
705
+ ensureProviderCollections(state);
706
+ const payout = getProviderPayout(state, payoutId);
707
+ if (!payout) return { ok: false, reason: "payout_not_found" };
708
+ if (dispatchState === "in_flight") {
709
+ payout.dispatch_state = "in_flight";
710
+ payout.dispatch_started_at_iso = nowIso();
711
+ } else {
712
+ payout.dispatch_state = null;
713
+ }
714
+ return { ok: true, payout };
715
+ }
716
+
717
+ // Last-resort safety net: a payout that was cancelled while (or after) the signer actually sent
718
+ // the funds is put back to PENDING with the reported tx and flagged, so the reservation is held
719
+ // again and the operator reconciles (confirm with the tx) instead of the ledger re-paying it.
720
+ function reinstateCancelledPayoutForReconciliation(state, payoutId, txRef) {
721
+ ensureProviderCollections(state);
722
+ const payout = getProviderPayout(state, payoutId);
723
+ if (!payout) return { ok: false, reason: "payout_not_found" };
724
+ if (payout.status !== "cancelled") return { ok: false, reason: "payout_not_cancelled", status: payout.status };
725
+ payout.status = "pending";
726
+ payout.cancelled_at_iso = null;
727
+ payout.dispatch_state = null;
728
+ payout.tx_ref = normStr(txRef) || payout.tx_ref || null;
729
+ payout.reconcile_required = true;
730
+ payout.note = [payout.note, "reinstated_after_cancel_during_dispatch"].filter(Boolean).join("; ");
731
+ return { ok: true, payout };
732
+ }
733
+
734
+ function listProviderPayouts(state, filters = {}) {
735
+ ensureProviderCollections(state);
736
+ let items = state.providerPayouts.slice();
737
+ const providerId = normStr(filters && filters.provider_id);
738
+ const status = normStr(filters && filters.status);
739
+ if (providerId) items = items.filter((p) => p.provider_id === providerId);
740
+ if (status) items = items.filter((p) => p.status === status);
741
+ if (hasEpochFilter(filters)) items = items.filter((p) => sameRegistrationEpoch(p, { registration_epoch: filters.registration_epoch }));
742
+ return items;
743
+ }
744
+
745
+ // Authenticate a provider acting on its OWN record using the owner_secret minted at
746
+ // registration (constant-time compare). Returns the record on match, or a reason code. This is
747
+ // the provider-SIDE auth (self-service), distinct from the operator bearer: it lets a
748
+ // self-registered provider read its own earnings without granting any operator capability.
749
+ function authenticateProviderOwner(state, providerId, ownerSecret) {
750
+ ensureProviderCollections(state);
751
+ const rec = state.providerRegistry.get(normStr(providerId));
752
+ if (!rec) return { ok: false, reason: "provider_not_found" };
753
+ if (!timingSafeStrEqual(normStr(ownerSecret), rec.owner_secret)) {
754
+ return { ok: false, reason: "provider_owner_mismatch" };
755
+ }
756
+ return { ok: true, provider: rec };
757
+ }
758
+
759
+ // Read-only self-service earnings view for a provider: its public profile, computed payout state
760
+ // (accrued / paid / pending / available / owed), its settlement ledger rows, and its payout
761
+ // history. Composes existing read helpers only — never mutates state, and never leaks auth_token
762
+ // or owner_secret (public view + the provider's own ledger rows). Caller must authenticate first.
763
+ function buildProviderEarningsView(state, providerId) {
764
+ ensureProviderCollections(state);
765
+ const rec = state.providerRegistry.get(normStr(providerId));
766
+ if (!rec) return null;
767
+ // Scoped to THIS registration's epoch: a re-registered id never reads a previous owner's rows.
768
+ const epoch = rec.registration_epoch || null;
769
+ return {
770
+ provider: buildProviderPublicView(rec),
771
+ payout_state: computeProviderPayoutState(state, rec.provider_id),
772
+ settlements: listProviderSettlements(state, { provider_id: rec.provider_id, registration_epoch: epoch }),
773
+ payouts: listProviderPayouts(state, { provider_id: rec.provider_id, registration_epoch: epoch })
774
+ };
775
+ }
776
+
777
+ function summarizeProviderNetwork(state) {
778
+ ensureProviderCollections(state);
779
+ const providers = Array.from(state.providerRegistry.values());
780
+ const byStatus = { pending: 0, active: 0, suspended: 0 };
781
+ const totals = { gross_minor: 0, provider_share_minor: 0, platform_take_minor: 0, paid_out_minor: 0, owed_minor: 0, routed_calls: 0, settled_calls: 0 };
782
+ for (const p of providers) {
783
+ byStatus[p.status] = (byStatus[p.status] || 0) + 1;
784
+ const share = rollupField(p, "provider_share_minor");
785
+ const paid = rollupField(p, "paid_out_minor");
786
+ totals.gross_minor += rollupField(p, "gross_minor");
787
+ totals.provider_share_minor += share;
788
+ totals.platform_take_minor += rollupField(p, "platform_take_minor");
789
+ totals.paid_out_minor += paid;
790
+ totals.owed_minor += Math.max(0, share - paid);
791
+ totals.routed_calls += rollupField(p, "routed_calls");
792
+ totals.settled_calls += rollupField(p, "settled_calls");
793
+ }
794
+ const nowMs = Date.now();
795
+ const health = { routable: 0, unhealthy: 0 };
796
+ for (const p of providers) {
797
+ if (p.status !== "active") continue;
798
+ if (healthRoutingState(p.health, nowMs).routable) health.routable += 1; else health.unhealthy += 1;
799
+ }
800
+ const pendingPayouts = state.providerPayouts.filter((p) => p.status === "pending");
801
+ return {
802
+ ok: true,
803
+ provider_count: providers.length,
804
+ by_status: byStatus,
805
+ health,
806
+ totals,
807
+ settlement_ledger_entries: state.providerSettlementLedger.length,
808
+ payouts: {
809
+ total: state.providerPayouts.length,
810
+ pending: pendingPayouts.length,
811
+ pending_minor: pendingPayouts.reduce((sum, p) => sum + (Number(p.amount_minor) || 0), 0)
812
+ }
813
+ };
814
+ }
815
+
816
+ module.exports = {
817
+ ensureProviderCollections,
818
+ registerProvider,
819
+ setProviderStatus,
820
+ getProvider,
821
+ removeProvider,
822
+ listProviders,
823
+ buildProviderPublicView,
824
+ authenticateProviderOwner,
825
+ buildProviderEarningsView,
826
+ resolveRegisteredTarget,
827
+ resolveRegisteredTargets,
828
+ hasActiveMatchingProvider,
829
+ recordProviderHealth,
830
+ recordProviderRouting,
831
+ recordProviderSettlement,
832
+ listProviderSettlements,
833
+ summarizeProviderNetwork,
834
+ computeProviderPayoutState,
835
+ createProviderPayout,
836
+ getProviderPayout,
837
+ confirmProviderPayout,
838
+ cancelProviderPayout,
839
+ markProviderPayoutDispatch,
840
+ reinstateCancelledPayoutForReconciliation,
841
+ listProviderPayouts
842
+ };