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.
- package/.env.example +8 -1
- package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
- package/RELEASE_NOTES.md +6 -4
- package/lib/a2a_rpc.js +280 -280
- package/lib/account_auth.js +585 -585
- package/lib/agent_identity.js +162 -162
- package/lib/bolt_adapter.js +187 -187
- package/lib/bolt_payment_front.js +224 -224
- package/lib/checkout_processor_adapter.js +217 -217
- package/lib/command_flow.js +722 -722
- package/lib/commerce_artifacts.js +591 -591
- package/lib/commerce_bridge.js +274 -274
- package/lib/commerce_cases.js +399 -399
- package/lib/commerce_client.js +7694 -7694
- package/lib/commerce_quotes.js +29 -0
- package/lib/commerce_runtime.js +322 -322
- package/lib/execution_dedupe.js +86 -86
- package/lib/external_credit_bridge.js +142 -142
- package/lib/http_transport.js +81 -81
- package/lib/l402_adapter.js +241 -241
- package/lib/l402_payment_front.js +265 -265
- package/lib/mpp_adapter.js +237 -237
- package/lib/payment_config.js +253 -253
- package/lib/payment_verification.js +357 -357
- package/lib/provider_payout_execution.js +155 -155
- package/lib/provider_registry.js +842 -842
- package/lib/rate_limiter.js +81 -81
- package/lib/runtime_state_store.js +491 -369
- package/lib/settlement_bsv_live.js +600 -600
- package/lib/settlement_bsv_teranode.js +117 -117
- package/lib/stripe_mpp_payment_front.js +258 -258
- package/lib/x402_facilitator.js +191 -191
- package/package.json +5 -3
- package/scripts/state_backup_offsite.js +196 -0
- package/scripts/verify_a2a_rpc.js +105 -105
- package/scripts/verify_agent_identity.js +153 -153
- package/scripts/verify_all.js +1 -1
- package/scripts/verify_bolt_adapter.js +128 -128
- package/scripts/verify_cdp_facilitator.js +110 -110
- package/scripts/verify_checkout_processor.js +109 -109
- package/scripts/verify_commerce_bridge.js +177 -177
- package/scripts/verify_execution_dedupe.js +189 -189
- package/scripts/verify_execution_surfacing.js +76 -76
- package/scripts/verify_final_activation.js +191 -191
- package/scripts/verify_l402_adapter.js +86 -86
- package/scripts/verify_mpp_adapter.js +128 -128
- package/scripts/verify_production_readiness.js +268 -268
- package/scripts/verify_rate_limiter.js +52 -52
- package/scripts/verify_refund_credit.js +106 -106
- package/scripts/verify_release_candidate.js +81 -81
- package/scripts/verify_runtime_state_guard.js +117 -0
- package/scripts/verify_service.js +15421 -15421
- package/scripts/verify_verify_execute_settle.js +208 -208
- package/scripts/verify_x402_facilitator.js +286 -286
- package/server.js +11976 -11974
package/lib/provider_registry.js
CHANGED
|
@@ -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
|
+
};
|