xytara 3.1.10 → 3.1.11
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 +61 -1
- package/README.md +1 -1
- package/RELEASE_NOTES.md +8 -4
- package/lib/capability_execution_truth.js +8 -4
- package/lib/command_flow.js +74 -8
- package/lib/external_execution_policy.js +143 -0
- package/lib/external_execution_runtime.js +48 -8
- package/lib/provider_network_contract.js +14 -9
- package/lib/provider_payout_execution.js +20 -2
- package/lib/provider_registry.js +186 -10
- package/package.json +2 -2
- package/scripts/verify_provider_network.js +224 -3
- package/server.js +32 -6
- package/examples/generated.carried-handoff.json +0 -30
package/.env.example
CHANGED
|
@@ -17,6 +17,7 @@ XYTARA_X402_TOPUP_UNITS=1000
|
|
|
17
17
|
# The runtime mints a fresh CDP Bearer JWT per request when both are set.
|
|
18
18
|
CDP_API_KEY_ID=
|
|
19
19
|
CDP_API_KEY_SECRET=
|
|
20
|
+
# (XYTARA_X402_CDP_KEY_ID / XYTARA_X402_CDP_KEY_SECRET are accepted as aliases for the two above)
|
|
20
21
|
XYTARA_PAYMENT_MAX_AGE_SECONDS=300
|
|
21
22
|
XYTARA_WALLET_REGISTRY_JSON=[]
|
|
22
23
|
XYTARA_TREASURY_DESTINATION_ID=
|
|
@@ -24,6 +25,9 @@ XYTARA_TREASURY_DESTINATION_KIND=
|
|
|
24
25
|
XYTARA_TREASURY_ACCOUNT_ID=
|
|
25
26
|
XYTARA_TREASURY_EXTERNAL_REF=
|
|
26
27
|
XYTARA_TREASURY_DESTINATIONS_JSON={}
|
|
28
|
+
# optional file-backed alternatives to the two *_JSON values above (blank = use the JSON values)
|
|
29
|
+
XYTARA_WALLET_REGISTRY_PATH=
|
|
30
|
+
XYTARA_TREASURY_DESTINATIONS_PATH=
|
|
27
31
|
XYTARA_BSV_TERANODE_NETWORK=mainnet
|
|
28
32
|
XYTARA_BSV_TERANODE_RPC_URL=
|
|
29
33
|
XYTARA_BSV_TERANODE_MODE=opreturn
|
|
@@ -37,6 +41,18 @@ XYTARA_BSV_TERANODE_SETTLEMENT_RUNTIME_MODE=mock
|
|
|
37
41
|
# operator write surfaces fail closed until both tokens are set
|
|
38
42
|
XYTARA_CREDIT_BRIDGE_BEARER_TOKEN=
|
|
39
43
|
XYTARA_ACCOUNT_AUTH_BEARER_TOKEN=
|
|
44
|
+
# commerce-bridge write surface (distinct from the credit bridge); also fails closed until set
|
|
45
|
+
XYTARA_COMMERCE_BRIDGE_BEARER_TOKEN=
|
|
46
|
+
# optional: require spend-proof binding on spend-credential use (blank = runtime default)
|
|
47
|
+
XYTARA_REQUIRE_SPEND_PROOF_BINDING=
|
|
48
|
+
# hosted-checkout webhook verification (blank = runtime defaults; set the secret before going live)
|
|
49
|
+
XYTARA_CHECKOUT_WEBHOOK_SECRET=
|
|
50
|
+
XYTARA_HOSTED_CHECKOUT_WEBHOOK_SECRET=
|
|
51
|
+
XYTARA_CHECKOUT_WEBHOOK_VERIFICATION_REQUIRED=
|
|
52
|
+
XYTARA_CHECKOUT_WEBHOOK_MAX_AGE_SECONDS=
|
|
53
|
+
# optional commerce pack map / prep TTL overrides (blank = runtime defaults)
|
|
54
|
+
XYTARA_COMMERCE_PACK_MAP_JSON=
|
|
55
|
+
XYTARA_COMMERCE_PREP_TTL_MS=
|
|
40
56
|
XYTARA_CALLBACK_DELIVERY_ENABLED=true
|
|
41
57
|
XYTARA_CALLBACK_SIGNING_SECRET=
|
|
42
58
|
XYTARA_CALLBACK_TIMEOUT_MS=5000
|
|
@@ -47,10 +63,52 @@ XYTARA_SETTLEMENT_REFRESH_INTERVAL_MS=1500
|
|
|
47
63
|
XYTARA_RATE_LIMIT_ENABLED=true
|
|
48
64
|
XYTARA_RATE_LIMIT_WINDOW_MS=60000
|
|
49
65
|
XYTARA_RATE_LIMIT_MAX_REQUESTS=600
|
|
50
|
-
|
|
66
|
+
# optional: scope the rate limit per route instead of globally (blank = runtime default)
|
|
67
|
+
XYTARA_RATE_LIMIT_ROUTE_SCOPED=
|
|
68
|
+
XYTARA_REQUEST_TIMEOUT_MS=30000
|
|
69
|
+
XYTARA_HEADERS_TIMEOUT_MS=15000
|
|
51
70
|
XYTARA_EXECUTION_TARGETS_JSON=[]
|
|
71
|
+
# External execution (env targets + registered providers). Cold-start ride-out retries ONLY on
|
|
72
|
+
# not-processed signals (408/429/503, connection refused/DNS). The budget is the TOTAL wall-clock for
|
|
73
|
+
# one buyer call across all retries AND failover candidates, kept inside the ~60s x402 authorization
|
|
74
|
+
# window so a ridden-out call still settles. Per-target timeout_ms is capped at 30000 for the same reason.
|
|
75
|
+
XYTARA_EXTERNAL_EXECUTION_RETRY_WINDOW_MS=45000
|
|
76
|
+
XYTARA_EXTERNAL_EXECUTION_MAX_RETRIES=29
|
|
77
|
+
XYTARA_EXTERNAL_EXECUTION_BUDGET_MS=50000
|
|
78
|
+
# Open execution-provider network (lib/provider_registry.js): providers self-register an endpoint +
|
|
79
|
+
# the capabilities they serve; the runtime routes paid calls to ACTIVE providers and splits revenue.
|
|
80
|
+
# Registration FAILS CLOSED until the bearer is set, UNLESS open registration is turned on. Every
|
|
81
|
+
# provider still lands pending until an operator activates it (the vetting gate). See the live
|
|
82
|
+
# machine contract at GET /v1/provider-network/contract.
|
|
83
|
+
XYTARA_PROVIDER_REGISTRY_BEARER_TOKEN=
|
|
84
|
+
# 1 = permissionless self-registration (no bearer); still rate-limited + pending-capped.
|
|
85
|
+
XYTARA_PROVIDER_OPEN_REGISTRATION=0
|
|
86
|
+
XYTARA_PROVIDER_REGISTRATION_RATE_PER_MIN=5
|
|
87
|
+
XYTARA_PROVIDER_REGISTRATION_RATE_GLOBAL_PER_MIN=60
|
|
88
|
+
XYTARA_PROVIDER_MAX_PENDING=100
|
|
89
|
+
# provider endpoint policy: strict (default when RENDER is set) = https only + no loopback/private/
|
|
90
|
+
# link-local/metadata hosts, re-checked by DNS at dispatch; permissive (default locally) for dev/tests
|
|
91
|
+
XYTARA_PROVIDER_ENDPOINT_POLICY=
|
|
92
|
+
# cap on a provider's revenue share, basis points (10000 = 100%)
|
|
93
|
+
XYTARA_PROVIDER_MAX_REVENUE_SHARE_BPS=10000
|
|
94
|
+
# health/failover: consecutive failures -> unhealthy; cooldown before a half-open retry; max candidates tried
|
|
95
|
+
XYTARA_PROVIDER_UNHEALTHY_THRESHOLD=3
|
|
96
|
+
XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS=60000
|
|
97
|
+
XYTARA_PROVIDER_FAILOVER_MAX=3
|
|
98
|
+
# routing reputation recency decay, factor in (0,1): fades older observations so recent performance
|
|
99
|
+
# weighs more. 1 (default) = no decay (lifetime counts). e.g. 0.95 once the network has real history.
|
|
100
|
+
XYTARA_PROVIDER_REPUTATION_DECAY=1
|
|
101
|
+
# Provider payout auto-execution via an EXTERNAL signer (the runtime stays key-less). OFF unless the
|
|
102
|
+
# URL is set; otherwise payouts are recorded and the operator sends + confirms them. See services/payout-signer.
|
|
103
|
+
XYTARA_PAYOUT_SIGNER_URL=
|
|
104
|
+
XYTARA_PAYOUT_SIGNER_BEARER=
|
|
105
|
+
XYTARA_PAYOUT_SIGNER_TIMEOUT_MS=20000
|
|
52
106
|
XYTARA_L402_ENABLED=false
|
|
53
107
|
XYTARA_L402_SHARED_SECRET=
|
|
108
|
+
# optional L402 tuning (blank = runtime defaults)
|
|
109
|
+
XYTARA_L402_MAX_AGE_SECONDS=
|
|
110
|
+
XYTARA_L402_INVOICE_RATE_PER_MIN=
|
|
111
|
+
XYTARA_L402_INVOICE_RATE_GLOBAL_PER_MIN=
|
|
54
112
|
XYTARA_BOLT_ENABLED=false
|
|
55
113
|
XYTARA_BOLT_SHARED_SECRET=
|
|
56
114
|
XYTARA_BOLT_NETWORK=mainnet
|
|
@@ -61,6 +119,8 @@ XYTARA_MPP_SHARED_SECRET=
|
|
|
61
119
|
XYTARA_MPP_PROVIDER=stripe_tempo_compatible
|
|
62
120
|
XYTARA_MPP_SETTLEMENT_FAMILY=cards_or_stablecoin_or_lightning
|
|
63
121
|
XYTARA_MPP_MAX_AGE_SECONDS=300
|
|
122
|
+
# optional minimum MPP amount in minor units (blank = runtime default)
|
|
123
|
+
XYTARA_MPP_MIN_AMOUNT_MINOR=
|
|
64
124
|
XYTARA_NETWORK_DNS_RESULT_ORDER=verbatim
|
|
65
125
|
XYTARA_NETWORK_PREFERRED_IP_FAMILY=auto
|
|
66
126
|
XYTARA_EXTERNAL_EXECUTION_IP_FAMILY=auto
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# xytara
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
Listed on [Smithery](https://smithery.ai/server/naxytra/xytara-mcp-executor) · [Glama](https://glama.ai/mcp/servers/naxytra/xytara-mcp-executor) · the official MCP registry (`com.naxytra/xytara-mcp-executor`).
|
|
4
4
|
|
|
5
5
|
**x402 pay-per-call agent-commerce runtime** — the payment and execution layer of the naxytra
|
|
6
6
|
machine stack. An agent discovers a capability, pays once with x402 (gasless USDC on Base), and gets
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
`xytara` 3.1.
|
|
1
|
+
`xytara` 3.1.11 hardens the open provider network's money ledger and the hosted executor, and tightens repository hygiene. No API is removed; the public provider directory now lists active providers only.
|
|
2
2
|
|
|
3
3
|
Highlights:
|
|
4
4
|
|
|
5
|
-
- **
|
|
6
|
-
- **
|
|
7
|
-
-
|
|
5
|
+
- **Settled-amount reconciliation** — a provider's revenue share accrues on min(quote, the amount actually settled on-chain) for x402 calls; an unrecognised settlement asset accrues nothing and is flagged. Every ledger row records its settlement basis (quoted vs. settled).
|
|
6
|
+
- **Registration-scoped ledgers** — ledger rows and payouts belong to one registration of a `provider_id`. A removed and re-registered id never sees, inherits, or is credited by a previous owner's rows, and removal cancels that registration's pending payouts.
|
|
7
|
+
- **In-flight payout guard** — a payout being sent by the external signer cannot be cancelled mid-dispatch (so no freed reservation can ever be re-sent); a payout cancelled before a late tx report is reinstated for reconciliation instead of being lost.
|
|
8
|
+
- **One execution budget per call** — cold-start ride-out retries and failover candidates share a single wall-clock budget inside the x402 authorization window, and per-target timeouts are capped at 30s, so a routed call can always still settle.
|
|
9
|
+
- **Provider endpoint policy** — on hosted deploys, provider endpoints must be public `https` (loopback, private, link-local and metadata hosts are refused and re-checked by DNS at dispatch); redirects are never followed. Providers receive only a sanitized payment context — never the buyer's signed authorization.
|
|
10
|
+
- The MCP executor's bearer check is timing-safe and fails closed on a hosted deploy; adapter execution truth counts only enabled targets; rate-limit identity compares are timing-safe.
|
|
11
|
+
- Repository hygiene: a complete `.gitignore`, the publish workflow waits for the live deploy before registry publication, and `.env.example` documents every runtime knob.
|
|
@@ -108,6 +108,9 @@ function classifyTask(task, settlementReadiness) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
if (normalizeString(task && task.public_category) === "adapter") {
|
|
111
|
+
// Only ENABLED targets make an adapter lane live — a bound-but-disabled target must not be
|
|
112
|
+
// reported as provider_live (adapter-depth filters the same way; the two surfaces must agree).
|
|
113
|
+
const enabledTargets = providerTargets.filter((target) => target.enabled);
|
|
111
114
|
return {
|
|
112
115
|
task_ref: task.public_task_ref,
|
|
113
116
|
internal_task_ref: task.task_ref,
|
|
@@ -115,10 +118,11 @@ function classifyTask(task, settlementReadiness) {
|
|
|
115
118
|
public_category: task.public_category,
|
|
116
119
|
execution_mode: task.execution_mode,
|
|
117
120
|
latency_class: task.latency_class,
|
|
118
|
-
execution_truth:
|
|
119
|
-
execution_origin:
|
|
120
|
-
commercialization_posture:
|
|
121
|
-
matching_execution_target_count:
|
|
121
|
+
execution_truth: enabledTargets.length > 0 ? "provider_live" : "provider_wiring_required",
|
|
122
|
+
execution_origin: enabledTargets.length > 0 ? "external_execution_target" : "adapter_lane_without_bound_target",
|
|
123
|
+
commercialization_posture: enabledTargets.length > 0 ? "sellable_live_now" : "sellable_by_integration",
|
|
124
|
+
matching_execution_target_count: enabledTargets.length,
|
|
125
|
+
disabled_execution_target_count: providerTargets.length - enabledTargets.length,
|
|
122
126
|
matching_execution_targets: providerTargets,
|
|
123
127
|
requirements: {
|
|
124
128
|
configured_provider_target_required: true
|
package/lib/command_flow.js
CHANGED
|
@@ -10,6 +10,7 @@ const { resolveSpendCredential, evaluateAuthorityBindingAction } = require("./ac
|
|
|
10
10
|
const { validateIntegrationSelection } = require("../integrations/dispatch");
|
|
11
11
|
const { markPaymentAccepted, settleDeferredPayment, verifyPaymentAcceptance } = require("./payment_verification");
|
|
12
12
|
const { executeExternalTarget, resolveExecutionTarget } = require("./external_execution_runtime");
|
|
13
|
+
const { externalExecutionBudgetMs } = require("./external_execution_policy");
|
|
13
14
|
const { resolveRegisteredTargets, hasActiveMatchingProvider, recordProviderHealth, recordProviderRouting, recordProviderSettlement } = require("./provider_registry");
|
|
14
15
|
const { claimDeferredExecution, releaseDeferredExecution } = require("./execution_dedupe");
|
|
15
16
|
|
|
@@ -94,12 +95,36 @@ function buildCreditRejectionResponse(creditDecision, quote) {
|
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// What a provider is allowed to see of the buyer's payment context: the verification outcome and
|
|
99
|
+
// scalar payment facts only. NEVER the signed x402 authorization / deferred-settlement material
|
|
100
|
+
// (that would hand a third-party provider the buyer's signature before settlement), and never
|
|
101
|
+
// signature-, secret- or raw-looking keys or nested objects from any lane's payload.
|
|
102
|
+
const SENSITIVE_CONTEXT_KEY = /signature|secret|private|authorization|raw|base64|header|token/i;
|
|
103
|
+
function sanitizeAuthorizationContextForProvider(context) {
|
|
104
|
+
if (!context || typeof context !== "object") return null;
|
|
105
|
+
const payload = context.payment_payload && typeof context.payment_payload === "object" ? context.payment_payload : null;
|
|
106
|
+
const safePayload = payload
|
|
107
|
+
? Object.fromEntries(Object.entries(payload).filter(([key, value]) => !SENSITIVE_CONTEXT_KEY.test(key) && (value === null || typeof value !== "object")))
|
|
108
|
+
: null;
|
|
109
|
+
return {
|
|
110
|
+
ok: context.ok === true,
|
|
111
|
+
verification_mode: context.verification_mode || null,
|
|
112
|
+
wallet_id: context.wallet_id || null,
|
|
113
|
+
age_seconds: Number.isFinite(Number(context.age_seconds)) ? Number(context.age_seconds) : null,
|
|
114
|
+
payment_payload: safePayload
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
async function buildExternalExecutionOptions(state, payload, quote, authorizationContext) {
|
|
119
|
+
// One wall-clock budget for the whole call (every retry of every candidate), inside the x402
|
|
120
|
+
// authorization window — see external_execution_policy.js.
|
|
121
|
+
const executionOptions = { deadline_ms: Date.now() + externalExecutionBudgetMs() };
|
|
122
|
+
const forwardedContext = sanitizeAuthorizationContextForProvider(authorizationContext);
|
|
98
123
|
// Operator-pinned env target resolves first and is used as-is (an explicit single target,
|
|
99
124
|
// no failover). Empty env + empty registry -> {} (unchanged behavior).
|
|
100
125
|
const envTarget = resolveExecutionTarget(payload, quote);
|
|
101
126
|
if (envTarget) {
|
|
102
|
-
const execution = await executeExternalTarget(envTarget, payload, quote,
|
|
127
|
+
const execution = await executeExternalTarget(envTarget, payload, quote, forwardedContext, executionOptions);
|
|
103
128
|
if (!execution || !execution.ok) {
|
|
104
129
|
return { error: execution || { ok: false, status: 502, reason: "external_execution_failed" } };
|
|
105
130
|
}
|
|
@@ -122,7 +147,12 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
|
|
|
122
147
|
}
|
|
123
148
|
let lastError = null;
|
|
124
149
|
for (const target of candidates) {
|
|
125
|
-
|
|
150
|
+
if (Date.now() >= executionOptions.deadline_ms) {
|
|
151
|
+
// Budget spent on earlier candidates: stop here rather than start a call that cannot settle.
|
|
152
|
+
lastError = lastError || { ok: false, status: 504, reason: "external_execution_budget_exhausted" };
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const execution = await executeExternalTarget(target, payload, quote, forwardedContext, executionOptions);
|
|
126
156
|
try { recordProviderRouting(state, target.registered_provider_id); } catch (_) { /* non-fatal */ }
|
|
127
157
|
if (execution && execution.ok) {
|
|
128
158
|
try { recordProviderHealth(state, target.registered_provider_id, { ok: true }); } catch (_) { /* non-fatal */ }
|
|
@@ -139,9 +169,38 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
|
|
|
139
169
|
return { error: lastError };
|
|
140
170
|
}
|
|
141
171
|
|
|
172
|
+
// Assets the x402 lane can settle in, with their on-chain decimals (needed to turn settled base
|
|
173
|
+
// units into the ledger's cents). Anything else cannot be reconciled and must not accrue on the quote.
|
|
174
|
+
const X402_ASSET_DECIMALS = new Map([
|
|
175
|
+
["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 6], // USDC, Base mainnet (eip155:8453)
|
|
176
|
+
["0x036cbd53842c5426634e7929541ec2318f3dcf7e", 6] // USDC, Base Sepolia (eip155:84532)
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
// The amount a provider's revenue share is computed from. The quote is what we ASKED for; on the
|
|
180
|
+
// x402 lane the facilitator settles exactly the advertised `accepts` amount, which may differ from
|
|
181
|
+
// the catalog quote — so accrue on min(quote, settled) and never on money that was not collected.
|
|
182
|
+
// Other lanes bind the paid amount to the quote at verification time (local_signed) or charge the
|
|
183
|
+
// quote directly (credits / hosted checkout), so the quote is the settled amount there.
|
|
184
|
+
function reconcileProviderSettlementAmount(quote, verification) {
|
|
185
|
+
const quoted = quote && Number.isFinite(Number(quote.amount_minor)) ? Math.max(0, Math.round(Number(quote.amount_minor))) : 0;
|
|
186
|
+
const mode = verification && typeof verification.verification_mode === "string" ? verification.verification_mode : null;
|
|
187
|
+
if (mode !== "x402_facilitator") {
|
|
188
|
+
return { amount_minor: quoted, quoted_minor: quoted, settled_minor: null, settlement_basis: mode ? `${mode}_quote` : "quote", reconciled: true };
|
|
189
|
+
}
|
|
190
|
+
const paymentPayload = (verification && verification.payment_payload) || {};
|
|
191
|
+
const decimals = X402_ASSET_DECIMALS.get(String(paymentPayload.x402_asset || "").toLowerCase());
|
|
192
|
+
const baseUnits = /^\d{1,30}$/.test(String(paymentPayload.x402_amount)) ? Number(paymentPayload.x402_amount) : NaN;
|
|
193
|
+
const centsCurrency = /^USD(_CENTS)?$/i.test(String((quote && quote.currency) || "USD_CENTS"));
|
|
194
|
+
if (decimals === undefined || !Number.isFinite(baseUnits) || !centsCurrency) {
|
|
195
|
+
return { amount_minor: 0, quoted_minor: quoted, settled_minor: null, settlement_basis: "x402_unreconciled_asset", reconciled: false };
|
|
196
|
+
}
|
|
197
|
+
const settledCents = Math.floor(baseUnits / Math.pow(10, decimals - 2));
|
|
198
|
+
return { amount_minor: Math.min(quoted, settledCents), quoted_minor: quoted, settled_minor: settledCents, settlement_basis: "x402_settled_min_quote", reconciled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
142
201
|
// Records the provider revenue-share split for a settled call routed to a registered
|
|
143
202
|
// provider. Fail-safe: metering must NEVER break or reverse a settled/charged response.
|
|
144
|
-
function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra) {
|
|
203
|
+
function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra, verification) {
|
|
145
204
|
const execution = externalExecutionOptions && externalExecutionOptions.externalExecution;
|
|
146
205
|
const providerId = execution && execution.registered_provider_id;
|
|
147
206
|
if (!providerId) return;
|
|
@@ -149,9 +208,14 @@ function attributeProviderSettlement(state, externalExecutionOptions, payload, q
|
|
|
149
208
|
const taskRef = (payload && payload.task_ref)
|
|
150
209
|
|| (Array.isArray(payload && payload.tasks) && payload.tasks[0] && payload.tasks[0].task_ref)
|
|
151
210
|
|| null;
|
|
211
|
+
const reconciled = reconcileProviderSettlementAmount(quote, verification);
|
|
152
212
|
recordProviderSettlement(state, {
|
|
153
213
|
provider_id: providerId,
|
|
154
|
-
amount_minor:
|
|
214
|
+
amount_minor: reconciled.amount_minor,
|
|
215
|
+
quoted_minor: reconciled.quoted_minor,
|
|
216
|
+
settled_minor: reconciled.settled_minor,
|
|
217
|
+
settlement_basis: reconciled.settlement_basis,
|
|
218
|
+
reconciled: reconciled.reconciled,
|
|
155
219
|
currency: quote && quote.currency ? quote.currency : null,
|
|
156
220
|
transaction_id: (extra && extra.transaction_id) || (quote && quote.quote_id) || null,
|
|
157
221
|
settlement_ref: (extra && extra.settlement_ref) || null,
|
|
@@ -299,7 +363,7 @@ async function executeCommandRequest(state, payload, headers, options) {
|
|
|
299
363
|
};
|
|
300
364
|
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
|
|
301
365
|
transaction_id: creditSpend.credit_spend.credit_spend_id
|
|
302
|
-
});
|
|
366
|
+
}, paymentVerification);
|
|
303
367
|
return {
|
|
304
368
|
status: 200,
|
|
305
369
|
payload: createCommandResult(state, {
|
|
@@ -486,7 +550,7 @@ async function executeCommandRequest(state, payload, headers, options) {
|
|
|
486
550
|
|
|
487
551
|
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
|
|
488
552
|
settlement_ref: (settledVerification.settlement && (settledVerification.settlement.transaction || settledVerification.settlement.txid)) || null
|
|
489
|
-
});
|
|
553
|
+
}, settledVerification);
|
|
490
554
|
return {
|
|
491
555
|
status: 200,
|
|
492
556
|
payload: createCommandResult(state, payload, quote, {
|
|
@@ -544,7 +608,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
|
|
|
544
608
|
};
|
|
545
609
|
}
|
|
546
610
|
|
|
547
|
-
attributeProviderSettlement(state, externalExecutionOptions, payload, quote);
|
|
611
|
+
attributeProviderSettlement(state, externalExecutionOptions, payload, quote, undefined, paymentVerification);
|
|
548
612
|
return {
|
|
549
613
|
status: 200,
|
|
550
614
|
payload: createCommandResult(state, payload, quote, {
|
|
@@ -557,5 +621,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
|
|
|
557
621
|
module.exports = {
|
|
558
622
|
executeAuthorizedCommandRequest,
|
|
559
623
|
executeCommandRequest,
|
|
560
|
-
buildExternalExecutionOptions
|
|
624
|
+
buildExternalExecutionOptions,
|
|
625
|
+
reconcileProviderSettlementAmount,
|
|
626
|
+
sanitizeAuthorizationContextForProvider
|
|
561
627
|
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const dns = require("dns").promises;
|
|
4
|
+
const net = require("net");
|
|
5
|
+
|
|
6
|
+
// Upper bound on a single provider call. The x402 (EIP-3009) authorization a buyer signs is valid
|
|
7
|
+
// for roughly a minute, so a provider allowed to run longer than this could do real work for a
|
|
8
|
+
// payment that then fails to settle (nobody pays). Applies to env targets and registered providers.
|
|
9
|
+
const MAX_TARGET_TIMEOUT_MS = 30000;
|
|
10
|
+
const DEFAULT_TARGET_TIMEOUT_MS = 10000;
|
|
11
|
+
const MIN_TARGET_TIMEOUT_MS = 500;
|
|
12
|
+
|
|
13
|
+
// Total wall-clock budget for ONE buyer call across cold-start ride-out retries AND failover
|
|
14
|
+
// candidates, shared so three candidates can never each consume a full window. Kept inside the
|
|
15
|
+
// x402 settlement window (~60s) with headroom for the settle call itself.
|
|
16
|
+
const DEFAULT_EXECUTION_BUDGET_MS = 50000;
|
|
17
|
+
const MAX_EXECUTION_BUDGET_MS = 55000;
|
|
18
|
+
|
|
19
|
+
function clampTargetTimeoutMs(value) {
|
|
20
|
+
const n = Number(value || DEFAULT_TARGET_TIMEOUT_MS) || DEFAULT_TARGET_TIMEOUT_MS;
|
|
21
|
+
return Math.min(MAX_TARGET_TIMEOUT_MS, Math.max(MIN_TARGET_TIMEOUT_MS, Math.round(n)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function externalExecutionBudgetMs() {
|
|
25
|
+
const raw = process.env.XYTARA_EXTERNAL_EXECUTION_BUDGET_MS;
|
|
26
|
+
if (raw === undefined || raw === "") return DEFAULT_EXECUTION_BUDGET_MS;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_EXECUTION_BUDGET_MS;
|
|
29
|
+
return Math.min(MAX_EXECUTION_BUDGET_MS, Math.max(1000, Math.round(n)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Endpoint policy. "strict" (default on a hosted deploy, i.e. when RENDER is set) requires https
|
|
33
|
+
// and refuses endpoints at loopback / private / link-local / metadata addresses, so a registered
|
|
34
|
+
// provider cannot turn the runtime into a request proxy into its own network (SSRF). "permissive"
|
|
35
|
+
// (default locally) keeps http://127.0.0.1-style endpoints usable for tests and development.
|
|
36
|
+
function endpointPolicyMode() {
|
|
37
|
+
const raw = String(process.env.XYTARA_PROVIDER_ENDPOINT_POLICY || "").trim().toLowerCase();
|
|
38
|
+
if (raw === "strict" || raw === "permissive") return raw;
|
|
39
|
+
return process.env.RENDER ? "strict" : "permissive";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPrivateIp(ip) {
|
|
43
|
+
const family = net.isIP(ip);
|
|
44
|
+
if (family === 4) {
|
|
45
|
+
const p = ip.split(".").map(Number);
|
|
46
|
+
if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
|
|
47
|
+
if (p[0] === 169 && p[1] === 254) return true; // link-local + cloud metadata
|
|
48
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
|
49
|
+
if (p[0] === 192 && p[1] === 168) return true;
|
|
50
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // carrier-grade NAT
|
|
51
|
+
if (p[0] >= 224) return true; // multicast / reserved / broadcast
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (family === 6) {
|
|
55
|
+
const lower = ip.toLowerCase();
|
|
56
|
+
if (lower === "::" || lower === "::1") return true;
|
|
57
|
+
if (lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd")) return true; // link-local, ULA
|
|
58
|
+
if (lower.startsWith("::ffff:")) return isPrivateIp(lower.slice(7)); // v4-mapped
|
|
59
|
+
if (lower.startsWith("ff")) return true; // multicast
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isBlockedHostname(hostname) {
|
|
66
|
+
const h = String(hostname || "").toLowerCase().replace(/\.$/, "");
|
|
67
|
+
if (!h || h === "localhost" || h.endsWith(".localhost")) return true;
|
|
68
|
+
if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localdomain")) return true;
|
|
69
|
+
if (h === "metadata" || h === "metadata.google.internal") return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hostOf(parsedUrl) {
|
|
74
|
+
return String(parsedUrl.hostname || "").replace(/^\[|\]$/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Synchronous shape check (used at registration): protocol + literal-host rules. No DNS here.
|
|
78
|
+
function checkEndpointUrlShape(endpointUrl) {
|
|
79
|
+
const policy = endpointPolicyMode();
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(String(endpointUrl || ""));
|
|
83
|
+
} catch (_) {
|
|
84
|
+
return { ok: false, reason: "endpoint_url_invalid", policy };
|
|
85
|
+
}
|
|
86
|
+
if (parsed.username || parsed.password) {
|
|
87
|
+
return { ok: false, reason: "endpoint_url_credentials_not_allowed", policy };
|
|
88
|
+
}
|
|
89
|
+
if (policy !== "strict") return { ok: true, policy };
|
|
90
|
+
if (parsed.protocol !== "https:") {
|
|
91
|
+
return { ok: false, reason: "endpoint_url_insecure", detail: "https:// is required for provider endpoints", policy };
|
|
92
|
+
}
|
|
93
|
+
const host = hostOf(parsed);
|
|
94
|
+
if (isBlockedHostname(host) || (net.isIP(host) && isPrivateIp(host))) {
|
|
95
|
+
return { ok: false, reason: "endpoint_url_not_public", detail: "loopback, private, link-local and metadata hosts are refused", policy };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, policy };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Dispatch-time check: re-validates the shape AND resolves the host, refusing any address in a
|
|
101
|
+
// private range (defeats DNS rebinding to an internal address after registration). Results are
|
|
102
|
+
// cached briefly so the hot path adds at most one lookup per host per minute.
|
|
103
|
+
const resolveCache = new Map();
|
|
104
|
+
const RESOLVE_CACHE_TTL_MS = 60000;
|
|
105
|
+
|
|
106
|
+
async function checkEndpointBeforeDispatch(endpointUrl) {
|
|
107
|
+
const shape = checkEndpointUrlShape(endpointUrl);
|
|
108
|
+
if (!shape.ok || shape.policy !== "strict") return shape;
|
|
109
|
+
let host;
|
|
110
|
+
try {
|
|
111
|
+
host = hostOf(new URL(String(endpointUrl)));
|
|
112
|
+
} catch (_) {
|
|
113
|
+
return { ok: false, reason: "endpoint_url_invalid", policy: shape.policy };
|
|
114
|
+
}
|
|
115
|
+
if (net.isIP(host)) return shape; // literal already checked above
|
|
116
|
+
const cached = resolveCache.get(host);
|
|
117
|
+
if (cached && cached.expires_at > Date.now()) return cached.result;
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
const addresses = await dns.lookup(host, { all: true, verbatim: true });
|
|
121
|
+
const bad = addresses.find((entry) => isPrivateIp(entry.address));
|
|
122
|
+
result = bad
|
|
123
|
+
? { ok: false, reason: "endpoint_url_not_public", detail: "endpoint resolves to a non-public address", policy: shape.policy }
|
|
124
|
+
: { ok: true, policy: shape.policy };
|
|
125
|
+
} catch (_) {
|
|
126
|
+
// Unresolvable host: let the fetch surface ENOTFOUND (a not-processed, failover-safe signal).
|
|
127
|
+
result = { ok: true, policy: shape.policy, unresolved: true };
|
|
128
|
+
}
|
|
129
|
+
resolveCache.set(host, { result, expires_at: Date.now() + RESOLVE_CACHE_TTL_MS });
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
MAX_TARGET_TIMEOUT_MS,
|
|
135
|
+
DEFAULT_TARGET_TIMEOUT_MS,
|
|
136
|
+
DEFAULT_EXECUTION_BUDGET_MS,
|
|
137
|
+
clampTargetTimeoutMs,
|
|
138
|
+
externalExecutionBudgetMs,
|
|
139
|
+
endpointPolicyMode,
|
|
140
|
+
isPrivateIp,
|
|
141
|
+
checkEndpointUrlShape,
|
|
142
|
+
checkEndpointBeforeDispatch
|
|
143
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const { buildFetchOptionsForPurpose } = require("./network_transport");
|
|
4
|
+
const { clampTargetTimeoutMs, checkEndpointBeforeDispatch } = require("./external_execution_policy");
|
|
4
5
|
|
|
5
6
|
function normalizeString(value, fallback = "") {
|
|
6
7
|
const raw = typeof value === "string" ? value.trim() : "";
|
|
@@ -35,7 +36,7 @@ function loadExecutionTargetRegistry() {
|
|
|
35
36
|
integration_id: normalizeString(item.integration_id) || null,
|
|
36
37
|
provider: normalizeString(item.provider, "external_execution"),
|
|
37
38
|
auth_token: normalizeString(item.auth_token) || null,
|
|
38
|
-
timeout_ms:
|
|
39
|
+
timeout_ms: clampTargetTimeoutMs(item.timeout_ms),
|
|
39
40
|
enabled: item.enabled !== false
|
|
40
41
|
};
|
|
41
42
|
})
|
|
@@ -95,9 +96,9 @@ function executionRetryDelayMs(attempt, retryAfterHeader) {
|
|
|
95
96
|
return Math.min(500 * Math.pow(2, attempt), 5000); // 500ms, 1000, 2000, 4000, cap 5000
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
async function executeExternalTargetOnce(target, body) {
|
|
99
|
+
async function executeExternalTargetOnce(target, body, timeoutMs) {
|
|
99
100
|
const controller = new AbortController();
|
|
100
|
-
const timer = setTimeout(() => controller.abort(), target.timeout_ms);
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || target.timeout_ms));
|
|
101
102
|
try {
|
|
102
103
|
const response = await fetch(target.endpoint_url, {
|
|
103
104
|
method: "POST",
|
|
@@ -107,6 +108,9 @@ async function executeExternalTargetOnce(target, body) {
|
|
|
107
108
|
},
|
|
108
109
|
body,
|
|
109
110
|
signal: controller.signal,
|
|
111
|
+
// Never follow a redirect: a provider must not be able to bounce the call (and its bearer)
|
|
112
|
+
// to another host, including an internal one.
|
|
113
|
+
redirect: "error",
|
|
110
114
|
...buildFetchOptionsForPurpose("external_execution")
|
|
111
115
|
});
|
|
112
116
|
const text = await response.text();
|
|
@@ -118,8 +122,39 @@ async function executeExternalTargetOnce(target, body) {
|
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
async function executeExternalTarget(target, payload, quote, authorizationContext) {
|
|
125
|
+
async function executeExternalTarget(target, payload, quote, authorizationContext, options) {
|
|
122
126
|
if (!target) return null;
|
|
127
|
+
const opts = ensureObject(options);
|
|
128
|
+
// One shared wall-clock budget for the WHOLE buyer call (every retry of every failover
|
|
129
|
+
// candidate), set by the caller so three candidates can never each burn a full ride-out window
|
|
130
|
+
// and push settlement past the x402 authorization expiry. Absent (direct callers) -> only the
|
|
131
|
+
// ride-out window below bounds this call.
|
|
132
|
+
const budgetDeadline = Number.isFinite(Number(opts.deadline_ms)) ? Number(opts.deadline_ms) : null;
|
|
133
|
+
const budgetExhausted = (attempts) => ({
|
|
134
|
+
ok: false,
|
|
135
|
+
status: 504,
|
|
136
|
+
reason: "external_execution_budget_exhausted",
|
|
137
|
+
provider: target.provider,
|
|
138
|
+
target_id: target.target_id,
|
|
139
|
+
failover_safe: false, // nothing left to spend on another candidate either
|
|
140
|
+
attempts
|
|
141
|
+
});
|
|
142
|
+
if (budgetDeadline !== null && Date.now() >= budgetDeadline) return budgetExhausted(0);
|
|
143
|
+
// Dispatch-time endpoint policy (strict on hosted deploys): refused BEFORE anything is sent, so
|
|
144
|
+
// this is a not-processed failure and failing over to the next candidate is safe.
|
|
145
|
+
const endpointCheck = await checkEndpointBeforeDispatch(target.endpoint_url);
|
|
146
|
+
if (!endpointCheck.ok) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
status: 502,
|
|
150
|
+
reason: "external_execution_endpoint_rejected",
|
|
151
|
+
details: endpointCheck.reason,
|
|
152
|
+
provider: target.provider,
|
|
153
|
+
target_id: target.target_id,
|
|
154
|
+
failover_safe: true,
|
|
155
|
+
attempts: 0
|
|
156
|
+
};
|
|
157
|
+
}
|
|
123
158
|
const body = JSON.stringify({
|
|
124
159
|
command: payload && payload.command ? payload.command : "command.execute",
|
|
125
160
|
task_ref: payload && payload.task_ref ? payload.task_ref : null,
|
|
@@ -143,17 +178,22 @@ async function executeExternalTarget(target, payload, quote, authorizationContex
|
|
|
143
178
|
? Math.max(0, Math.min(39, Number(process.env.XYTARA_EXTERNAL_EXECUTION_MAX_RETRIES) || 0))
|
|
144
179
|
: 29;
|
|
145
180
|
const maxAttempts = maxRetries + 1;
|
|
146
|
-
const
|
|
181
|
+
const rideOutDeadline = Date.now() + retryWindowMs;
|
|
182
|
+
const deadline = budgetDeadline !== null ? Math.min(rideOutDeadline, budgetDeadline) : rideOutDeadline;
|
|
183
|
+
const remainingBudgetMs = () => (budgetDeadline !== null ? budgetDeadline - Date.now() : Infinity);
|
|
184
|
+
// A retry sleep never runs past the deadline, and a single attempt never outlives the budget.
|
|
185
|
+
const boundedSleep = (ms) => sleep(Math.max(0, Math.min(ms, deadline - Date.now())));
|
|
147
186
|
|
|
148
187
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
188
|
+
if (remainingBudgetMs() <= 0) return budgetExhausted(attempt);
|
|
149
189
|
try {
|
|
150
|
-
const r = await executeExternalTargetOnce(target, body);
|
|
190
|
+
const r = await executeExternalTargetOnce(target, body, Math.min(target.timeout_ms, remainingBudgetMs()));
|
|
151
191
|
if (r.ok) {
|
|
152
192
|
return { ok: true, provider: target.provider, target_id: target.target_id, target_response: r.parsed || null };
|
|
153
193
|
}
|
|
154
194
|
// Retry only a provider-rejected (not-processed) status, while the ride-out budget lasts.
|
|
155
195
|
if (RETRYABLE_EXECUTION_STATUS.has(r.status) && Date.now() < deadline && attempt + 1 < maxAttempts) {
|
|
156
|
-
await
|
|
196
|
+
await boundedSleep(executionRetryDelayMs(attempt, r.retry_after));
|
|
157
197
|
continue;
|
|
158
198
|
}
|
|
159
199
|
return {
|
|
@@ -173,7 +213,7 @@ async function executeExternalTarget(target, payload, quote, authorizationContex
|
|
|
173
213
|
// -> not processed -> safe to ride out too. A timeout or mid-stream reset is AMBIGUOUS -> stop.
|
|
174
214
|
const notProcessed = !isAbort && NOT_PROCESSED_NET_CODES.has(connectErrorCode(error));
|
|
175
215
|
if (notProcessed && Date.now() < deadline && attempt + 1 < maxAttempts) {
|
|
176
|
-
await
|
|
216
|
+
await boundedSleep(executionRetryDelayMs(attempt, null));
|
|
177
217
|
continue;
|
|
178
218
|
}
|
|
179
219
|
return {
|
|
@@ -31,7 +31,7 @@ function buildProviderNetworkContract() {
|
|
|
31
31
|
"3. An OPERATOR activates it (status:'active').",
|
|
32
32
|
"4. When a buyer pays (x402) for a capability you serve, the runtime POSTs the job to your endpoint_url; you execute and return the deliverable.",
|
|
33
33
|
"5. On your success the payment settles on-chain and your revenue_share accrues.",
|
|
34
|
-
"6. The operator pays out your accrued balance to your payout_address (manually or via a signer)."
|
|
34
|
+
"6. The operator pays out your accrued balance to your payout_address (manually or via a signer). Track it yourself any time via GET /providers/<id>/earnings with your owner_secret."
|
|
35
35
|
],
|
|
36
36
|
|
|
37
37
|
registration: {
|
|
@@ -82,9 +82,11 @@ function buildProviderNetworkContract() {
|
|
|
82
82
|
body: "object — the buyer's input, e.g. { tool_name, arguments }",
|
|
83
83
|
account_id: "string — the buyer's account ref",
|
|
84
84
|
quote: "object — the priced quote for this call",
|
|
85
|
-
authorization_context: "object — payment
|
|
85
|
+
authorization_context: "object — SANITIZED payment context: { ok, verification_mode, wallet_id, age_seconds, payment_payload (scalar facts only) }. Never the buyer's signed authorization, signatures, or settlement material."
|
|
86
86
|
}
|
|
87
87
|
},
|
|
88
|
+
endpoint_policy: "endpoint_url must be https and publicly routable on the hosted network (loopback, private, link-local and metadata hosts are refused, re-checked by DNS at dispatch); URLs with embedded credentials are refused; redirects are never followed.",
|
|
89
|
+
execution_budget: "One buyer call has a total wall-clock budget (default 50s, inside the x402 authorization window) shared across ride-out retries and failover; your timeout_ms is capped at 30000 ms. Finish well inside it — a call that outlives the authorization cannot settle.",
|
|
88
90
|
response: {
|
|
89
91
|
on_success: "Return HTTP 2xx with the deliverable. The runtime surfaces your output from result.output OR a top-level output_payload / output_ref.",
|
|
90
92
|
example_success_bodies: [
|
|
@@ -108,8 +110,9 @@ function buildProviderNetworkContract() {
|
|
|
108
110
|
unhealthy_cooldown_ms: intEnv("XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS", 60000)
|
|
109
111
|
},
|
|
110
112
|
reputation: {
|
|
111
|
-
description: "The runtime tracks your lifetime success rate and prefers higher-reputation providers within the healthy tier. New providers get an optimistic score (a fair first shot); one early failure is recoverable (not disqualifying).",
|
|
112
|
-
routing_score: "(success + 2) / (success + failure + 2)"
|
|
113
|
+
description: "The runtime tracks your lifetime success rate and prefers higher-reputation providers within the healthy tier. New providers get an optimistic score (a fair first shot); one early failure is recoverable (not disqualifying). The operator may enable recency decay so recent performance outweighs stale history (your raw lifetime counts are still shown either way).",
|
|
114
|
+
routing_score: "(success + 2) / (success + failure + 2)",
|
|
115
|
+
recency_decay: intEnv("XYTARA_PROVIDER_REPUTATION_DECAY", 1)
|
|
113
116
|
},
|
|
114
117
|
failover_max_candidates: intEnv("XYTARA_PROVIDER_FAILOVER_MAX", 3),
|
|
115
118
|
note: "Operator-pinned execution targets (if any) take precedence over the registry for a given capability."
|
|
@@ -117,13 +120,15 @@ function buildProviderNetworkContract() {
|
|
|
117
120
|
|
|
118
121
|
economics: {
|
|
119
122
|
settlement: "Buyers pay via x402 (gasless USDC on Base). The runtime settles on-chain only AFTER your execution succeeds — a failed call does not charge the buyer.",
|
|
120
|
-
revenue_share: "Your provider_share = floor(settled_amount * revenue_share_bps / 10000); the platform keeps the remainder (incl. rounding).",
|
|
121
|
-
payouts: "Your accrued share is paid to your payout_address by the operator (reconciled ledger: create -> on-chain send -> confirm). Amounts are tracked in the quote's minor unit (USD_CENTS)."
|
|
123
|
+
revenue_share: "Your provider_share = floor(settled_amount * revenue_share_bps / 10000); the platform keeps the remainder (incl. rounding). settled_amount = min(quote, the amount actually settled on-chain for the call) — never money that was not collected; each ledger row records its settlement_basis.",
|
|
124
|
+
payouts: "Your accrued share is paid to your payout_address by the operator (reconciled ledger: create -> on-chain send -> confirm). Amounts are tracked in the quote's minor unit (USD_CENTS). Ledger rows and payouts are scoped to your registration: a removed-and-re-registered provider_id starts a fresh ledger and never inherits another owner's rows."
|
|
122
125
|
},
|
|
123
126
|
|
|
124
127
|
discovery: {
|
|
125
|
-
list: "GET /v1/provider-network/providers — public;
|
|
126
|
-
self: "GET /v1/provider-network/providers/<provider_id> — your public record."
|
|
128
|
+
list: "GET /v1/provider-network/providers — public; lists ACTIVE (vetted) providers with capabilities, status, health, and reputation (never secrets). Pending/suspended records are visible to the operator only.",
|
|
129
|
+
self: "GET /v1/provider-network/providers/<provider_id> — your public record.",
|
|
130
|
+
earnings:
|
|
131
|
+
"GET /v1/provider-network/providers/<provider_id>/earnings — your OWN earnings (read-only): payout_state (accrued/paid/pending/available/owed), your settlement rows, and your payout history. Authenticate with YOUR owner_secret via the 'x-provider-owner-secret' header (or Authorization: Bearer <owner_secret>) — this is the provider side, it needs no operator bearer."
|
|
127
132
|
},
|
|
128
133
|
|
|
129
134
|
operator_gated: [
|
|
@@ -133,7 +138,7 @@ function buildProviderNetworkContract() {
|
|
|
133
138
|
],
|
|
134
139
|
|
|
135
140
|
guidance: [
|
|
136
|
-
"Be idempotent per request — retries and failover can re-send a provably-not-processed job.",
|
|
141
|
+
"Be idempotent per request — key on quote.quote_id (unique per buyer call): retries and failover can re-send a provably-not-processed job, and some proxies return 503 after forwarding a request.",
|
|
137
142
|
"Be reliable — health and reputation gate your routing and ordering.",
|
|
138
143
|
"Return a 429 with Retry-After when overloaded (it is treated as 'did not process' and is safely retried)."
|
|
139
144
|
]
|