1use std::env;
5use std::fs;
6use std::path::PathBuf;
7
8use runx_contracts::{
9 ExternalAdapterInvocation, ExternalAdapterInvocationSchema, ExternalAdapterManifest,
10 ExternalAdapterProtocolVersion, JsonObject, JsonValue, Reference, ReferenceType,
11};
12use runx_pay::{
13 DeterministicPaymentFinalitySupervisor, PaymentFinalitySupervisor,
14 PaymentFinalitySupervisorError, PaymentFinalitySupervisorEvidence,
15 PaymentFinalitySupervisorRequest, PaymentRuntimeEffect,
16 ledger::{X402_PAY_PAYMENT_PROFILE, persist_x402_payment_ledger_projection_event},
17 supervisor::{
18 payment_finality_supervisor_evidence_payload, payment_supervisor_evidence_from_payload,
19 },
20};
21use runx_runtime::{
22 CredentialDelivery, HarnessReplayOutput, LocalOrchestrator, ProviderPermissionEffect,
23 RUNX_RECEIPT_DIR_ENV, RuntimeEffectRegistry,
24 adapters::external_adapter::{
25 ExternalAdapterProcessOutcome, ExternalAdapterProcessSupervisor, ExternalAdapterSupervisor,
26 },
27};
28
29pub const RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV: &str =
30 "RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST";
31const PAYMENT_FINALITY_SUPERVISOR_SKILL_REF: &str = "runx/payment-finality-supervisor";
32
33#[must_use]
34pub fn local_orchestrator() -> LocalOrchestrator {
35 LocalOrchestrator::with_effects(payment_effect_registry())
36}
37
38#[must_use]
39pub fn payment_effect_registry() -> RuntimeEffectRegistry {
40 let mut registry = RuntimeEffectRegistry::with_effect(PaymentRuntimeEffect::new(
41 ConfiguredPaymentFinalitySupervisor::from_env(),
42 ));
43 let _ = registry.register_effect(ProviderPermissionEffect);
44 registry
45}
46
47pub fn persist_payment_ledger_projection(output: &HarnessReplayOutput) -> Result<(), String> {
48 if metadata_string(output, "payment_ledger_profile") != Some(X402_PAY_PAYMENT_PROFILE) {
49 return Ok(());
50 }
51 let Some(receipt_dir) = env::var_os(RUNX_RECEIPT_DIR_ENV).map(PathBuf::from) else {
52 return Ok(());
53 };
54 let scenario_id = metadata_string(output, "payment_ledger_scenario_id")
55 .ok_or_else(|| "metadata.payment_ledger_scenario_id is required".to_owned())?;
56 persist_x402_payment_ledger_projection_event(
57 receipt_dir,
58 &format!("gx_{}", output.fixture.name),
59 output.receipt.created_at.as_str(),
60 &output.receipt,
61 &output.steps,
62 scenario_id,
63 )
64 .map(|_| ())
65 .map_err(|error| error.to_string())
66}
67
68fn metadata_string<'a>(output: &'a HarnessReplayOutput, key: &str) -> Option<&'a str> {
69 output
70 .fixture
71 .metadata
72 .get(key)
73 .and_then(|value| match value {
74 JsonValue::String(value) => Some(value.as_str()),
75 _ => None,
76 })
77}
78
79enum ConfiguredPaymentFinalitySupervisor {
80 Deterministic(DeterministicPaymentFinalitySupervisor),
81 External(Box<ExternalAdapterPaymentFinalitySupervisor>),
82 Unavailable(String),
83}
84
85impl ConfiguredPaymentFinalitySupervisor {
86 fn from_env() -> Self {
87 let Some(path) = env::var_os(RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV) else {
88 return Self::Deterministic(DeterministicPaymentFinalitySupervisor);
89 };
90 let path = PathBuf::from(path);
91 match ExternalAdapterPaymentFinalitySupervisor::from_manifest_path(path.clone()) {
92 Ok(supervisor) => Self::External(Box::new(supervisor)),
93 Err(message) => Self::Unavailable(format!(
94 "{}={} is invalid: {message}",
95 RUNX_PAYMENT_FINALITY_SUPERVISOR_MANIFEST_ENV,
96 path.display()
97 )),
98 }
99 }
100}
101
102impl PaymentFinalitySupervisor for ConfiguredPaymentFinalitySupervisor {
103 fn supervise(
104 &self,
105 request: PaymentFinalitySupervisorRequest<'_>,
106 ) -> Result<PaymentFinalitySupervisorEvidence, PaymentFinalitySupervisorError> {
107 match self {
108 Self::Deterministic(supervisor) => supervisor.supervise(request),
109 Self::External(supervisor) => supervisor.supervise(request),
110 Self::Unavailable(message) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
111 message: message.clone(),
112 }),
113 }
114 }
115}
116
117struct ExternalAdapterPaymentFinalitySupervisor<S = ExternalAdapterProcessSupervisor> {
118 manifest: ExternalAdapterManifest,
119 supervisor: S,
120}
121
122impl ExternalAdapterPaymentFinalitySupervisor {
123 fn from_manifest_path(path: PathBuf) -> Result<Self, String> {
124 let raw = fs::read_to_string(&path)
125 .map_err(|source| format!("could not read manifest: {source}"))?;
126 let manifest: ExternalAdapterManifest = serde_json::from_str(&raw)
127 .map_err(|source| format!("manifest JSON is invalid: {source}"))?;
128 Ok(Self::new(manifest, ExternalAdapterProcessSupervisor))
129 }
130}
131
132impl<S> ExternalAdapterPaymentFinalitySupervisor<S> {
133 fn new(manifest: ExternalAdapterManifest, supervisor: S) -> Self {
134 Self {
135 manifest,
136 supervisor,
137 }
138 }
139}
140
141impl<S> PaymentFinalitySupervisor for ExternalAdapterPaymentFinalitySupervisor<S>
142where
143 S: ExternalAdapterSupervisor + Send + Sync,
144{
145 fn supervise(
146 &self,
147 request: PaymentFinalitySupervisorRequest<'_>,
148 ) -> Result<PaymentFinalitySupervisorEvidence, PaymentFinalitySupervisorError> {
149 let invocation = payment_finality_invocation(&self.manifest, &request)?;
150 let outcome = self
151 .supervisor
152 .invoke_external_adapter(&self.manifest, &invocation, &CredentialDelivery::none())
153 .map_err(|source| PaymentFinalitySupervisorError::InvalidEvidence {
154 message: format!("external adapter payment finality supervisor failed: {source}"),
155 })?;
156 let payload = payment_finality_payload_from_outcome(outcome)?;
157 let evidence = payment_supervisor_evidence_from_payload(&payload).map_err(|source| {
158 PaymentFinalitySupervisorError::InvalidEvidence {
159 message: source.to_string(),
160 }
161 })?;
162 Ok(PaymentFinalitySupervisorEvidence::new(
163 request.family,
164 payment_finality_supervisor_evidence_payload(&evidence),
165 ))
166 }
167}
168
169fn payment_finality_invocation(
170 manifest: &ExternalAdapterManifest,
171 request: &PaymentFinalitySupervisorRequest<'_>,
172) -> Result<ExternalAdapterInvocation, PaymentFinalitySupervisorError> {
173 let proof_ref = required_payload_string(&request.payload, "proof_ref")?;
174 let invocation_id = format!("payment_finality.{}.invoke", identifier_segment(proof_ref));
175 let run_id = format!("payment_finality.{}", identifier_segment(proof_ref));
176 let mut inputs = request.payload.clone();
177 inputs.insert(
178 "effect_family".to_owned(),
179 JsonValue::String(request.family.to_owned()),
180 );
181 Ok(ExternalAdapterInvocation {
182 schema: ExternalAdapterInvocationSchema::V1,
183 protocol_version: ExternalAdapterProtocolVersion::V1,
184 invocation_id: invocation_id.into(),
185 adapter_id: manifest.adapter_id.clone(),
186 run_id: run_id.clone().into(),
187 step_id: "payment_finality".into(),
188 source_type: "external-adapter".into(),
189 skill_ref: PAYMENT_FINALITY_SUPERVISOR_SKILL_REF.into(),
190 harness_ref: Reference::with_uri(ReferenceType::Harness, format!("runx:harness:{run_id}")),
191 host_ref: Reference::with_uri(ReferenceType::Host, "runx:host:cli"),
192 inputs,
193 resolved_inputs: None,
194 cwd: env::current_dir()
195 .ok()
196 .map(|path| path.to_string_lossy().into_owned())
197 .filter(|value| !value.is_empty())
198 .map(Into::into),
199 receipt_dir: env::var(RUNX_RECEIPT_DIR_ENV).ok().map(Into::into),
200 env: None,
201 credential_refs: None,
202 metadata: None,
203 })
204}
205
206fn payment_finality_payload_from_outcome(
207 outcome: ExternalAdapterProcessOutcome,
208) -> Result<JsonObject, PaymentFinalitySupervisorError> {
209 let Some(output) = outcome.response.output else {
210 return Err(PaymentFinalitySupervisorError::InvalidEvidence {
211 message: "external adapter payment finality supervisor returned no output".to_owned(),
212 });
213 };
214 match output.get("payment_finality_evidence") {
215 Some(JsonValue::Object(payload)) => Ok(payload.clone()),
216 Some(_) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
217 message: "external adapter payment_finality_evidence must be an object".to_owned(),
218 }),
219 None => Ok(output),
220 }
221}
222
223fn required_payload_string<'a>(
224 payload: &'a JsonObject,
225 field: &'static str,
226) -> Result<&'a str, PaymentFinalitySupervisorError> {
227 match payload.get(field) {
228 Some(JsonValue::String(value)) if !value.is_empty() => Ok(value),
229 Some(_) => Err(PaymentFinalitySupervisorError::InvalidEvidence {
230 message: format!("payment finality request field {field} must be a non-empty string"),
231 }),
232 None => Err(PaymentFinalitySupervisorError::InvalidEvidence {
233 message: format!("payment finality request field {field} is missing"),
234 }),
235 }
236}
237
238fn identifier_segment(value: &str) -> String {
239 let segment: String = value
240 .chars()
241 .map(|ch| {
242 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
243 ch
244 } else {
245 '_'
246 }
247 })
248 .collect();
249 if segment.is_empty() {
250 "unknown".to_owned()
251 } else {
252 segment
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use std::sync::Mutex;
259
260 use runx_contracts::{
261 EXTERNAL_ADAPTER_PROTOCOL_VERSION, ExternalAdapterResponse, ExternalAdapterSandboxIntent,
262 ExternalAdapterStatus, ExternalAdapterTimeouts, ExternalAdapterTransport,
263 ExternalAdapterTransportKind, JsonNumber,
264 };
265 use runx_pay::PAYMENT_EFFECT_FAMILY;
266
267 use super::*;
268
269 #[test]
270 fn external_adapter_payment_finality_supervisor_round_trips_evidence() -> Result<(), String> {
271 let supervisor = RecordingSupervisor::with_payload(nested_evidence_output());
272 let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
273 let result = adapter
274 .supervise(test_request())
275 .map_err(|error| error.to_string())?;
276
277 assert_eq!(result.family, PAYMENT_EFFECT_FAMILY);
278 assert_eq!(
279 result.payload.get("proof_ref"),
280 Some(&JsonValue::String("proof_x402_1".to_owned()))
281 );
282 let invocation = adapter
283 .supervisor
284 .last_invocation
285 .lock()
286 .map_err(|_| "recording supervisor lock poisoned".to_owned())?
287 .clone()
288 .ok_or_else(|| "invocation captured".to_owned())?;
289 assert_eq!(invocation.source_type.as_str(), "external-adapter");
290 assert_eq!(
291 invocation.inputs.get("effect_family"),
292 Some(&JsonValue::String(PAYMENT_EFFECT_FAMILY.to_owned()))
293 );
294 Ok(())
295 }
296
297 #[test]
298 fn external_adapter_payment_finality_supervisor_accepts_direct_evidence_output()
299 -> Result<(), String> {
300 let supervisor = RecordingSupervisor::with_payload(evidence_payload());
301 let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
302 let result = adapter
303 .supervise(test_request())
304 .map_err(|error| error.to_string())?;
305
306 assert_eq!(
307 result.payload.get("provider_event_ref"),
308 Some(&JsonValue::String("0xfeed".to_owned()))
309 );
310 Ok(())
311 }
312
313 #[test]
314 fn external_adapter_payment_finality_supervisor_rejects_missing_output() {
315 let supervisor = RecordingSupervisor::without_output();
316 let adapter = ExternalAdapterPaymentFinalitySupervisor::new(test_manifest(), supervisor);
317 let result = adapter.supervise(test_request());
318
319 assert!(matches!(
320 result,
321 Err(PaymentFinalitySupervisorError::InvalidEvidence { .. })
322 ));
323 }
324
325 struct RecordingSupervisor {
326 output: Option<JsonObject>,
327 last_invocation: Mutex<Option<ExternalAdapterInvocation>>,
328 }
329
330 impl RecordingSupervisor {
331 fn with_payload(output: JsonObject) -> Self {
332 Self {
333 output: Some(output),
334 last_invocation: Mutex::new(None),
335 }
336 }
337
338 fn without_output() -> Self {
339 Self {
340 output: None,
341 last_invocation: Mutex::new(None),
342 }
343 }
344 }
345
346 impl ExternalAdapterSupervisor for RecordingSupervisor {
347 fn invoke_external_adapter(
348 &self,
349 manifest: &ExternalAdapterManifest,
350 invocation: &ExternalAdapterInvocation,
351 _credential_delivery: &CredentialDelivery,
352 ) -> Result<
353 ExternalAdapterProcessOutcome,
354 runx_runtime::adapters::external_adapter::ExternalAdapterSupervisorError,
355 > {
356 *self.last_invocation.lock().map_err(|_| {
357 runx_runtime::adapters::external_adapter::ExternalAdapterSupervisorError::Io {
358 context: "recording external adapter invocation".to_owned(),
359 source: std::io::Error::other("recording supervisor lock poisoned"),
360 }
361 })? = Some(invocation.clone());
362 Ok(ExternalAdapterProcessOutcome {
363 response: ExternalAdapterResponse {
364 schema: "runx.external_adapter.response.v1".to_owned(),
365 protocol_version: EXTERNAL_ADAPTER_PROTOCOL_VERSION.to_owned(),
366 invocation_id: invocation.invocation_id.to_string(),
367 adapter_id: manifest.adapter_id.to_string(),
368 status: ExternalAdapterStatus::Completed,
369 stdout: None,
370 stderr: None,
371 exit_code: Some(Some(0)),
372 output: self.output.clone(),
373 artifacts: None,
374 errors: None,
375 telemetry: None,
376 metadata: None,
377 observed_at: "2026-06-11T00:00:00Z".to_owned(),
378 },
379 process_exit_code: Some(0),
380 duration_ms: 1,
381 })
382 }
383 }
384
385 fn test_manifest() -> ExternalAdapterManifest {
386 ExternalAdapterManifest {
387 schema: runx_contracts::ExternalAdapterManifestSchema::V1,
388 protocol_version: ExternalAdapterProtocolVersion::V1,
389 adapter_id: "x402-finality-test".into(),
390 name: "x402 finality test".into(),
391 version: "0.1.0".into(),
392 supported_source_types: vec!["external-adapter".into()],
393 transport: ExternalAdapterTransport {
394 kind: ExternalAdapterTransportKind::Process,
395 command: Some("node".into()),
396 args: Some(vec!["scripts/x402-testnet-settle.mjs".to_owned()]),
397 endpoint: None,
398 },
399 timeouts: ExternalAdapterTimeouts {
400 startup_ms: 1_000,
401 invocation_ms: 30_000,
402 },
403 credential_needs: None,
404 sandbox_intent: ExternalAdapterSandboxIntent {
405 profile: "network".into(),
406 network: true,
407 cwd_policy: "workspace".into(),
408 writable_paths: None,
409 },
410 metadata: None,
411 }
412 }
413
414 fn test_request() -> PaymentFinalitySupervisorRequest<'static> {
415 PaymentFinalitySupervisorRequest {
416 family: PAYMENT_EFFECT_FAMILY,
417 payload: supervisor_payload(),
418 }
419 }
420
421 fn supervisor_payload() -> JsonObject {
422 let mut payload = JsonObject::new();
423 payload.insert(
424 "skill_settlement_status".to_owned(),
425 JsonValue::String("fulfilled".to_owned()),
426 );
427 payload.insert(
428 "proof_ref".to_owned(),
429 JsonValue::String("proof_x402_1".to_owned()),
430 );
431 payload.insert("rail".to_owned(), JsonValue::String("x402".to_owned()));
432 payload.insert(
433 "counterparty".to_owned(),
434 JsonValue::String("merchant:demo".to_owned()),
435 );
436 payload.insert(
437 "amount_minor".to_owned(),
438 JsonValue::Number(JsonNumber::U64(125)),
439 );
440 payload.insert("currency".to_owned(), JsonValue::String("USD".to_owned()));
441 payload.insert(
442 "idempotency_key".to_owned(),
443 JsonValue::String("idem_1".to_owned()),
444 );
445 payload
446 }
447
448 fn nested_evidence_output() -> JsonObject {
449 let mut output = JsonObject::new();
450 output.insert(
451 "payment_finality_evidence".to_owned(),
452 JsonValue::Object(evidence_payload()),
453 );
454 output
455 }
456
457 fn evidence_payload() -> JsonObject {
458 let mut payload = JsonObject::new();
459 payload.insert(
460 "verifier_id".to_owned(),
461 JsonValue::String(runx_pay::supervisor::PAYMENT_RAIL_SUPERVISOR_VERIFIER_ID.to_owned()),
462 );
463 payload.insert(
464 "proof_ref".to_owned(),
465 JsonValue::String("proof_x402_1".to_owned()),
466 );
467 payload.insert("rail".to_owned(), JsonValue::String("x402".to_owned()));
468 payload.insert(
469 "counterparty".to_owned(),
470 JsonValue::String("merchant:demo".to_owned()),
471 );
472 payload.insert(
473 "amount_minor".to_owned(),
474 JsonValue::Number(JsonNumber::U64(125)),
475 );
476 payload.insert("currency".to_owned(), JsonValue::String("USD".to_owned()));
477 payload.insert(
478 "idempotency_key".to_owned(),
479 JsonValue::String("idem_1".to_owned()),
480 );
481 payload.insert(
482 "settlement_status".to_owned(),
483 JsonValue::String("fulfilled".to_owned()),
484 );
485 payload.insert(
486 "provider_event_ref".to_owned(),
487 JsonValue::String("0xfeed".to_owned()),
488 );
489 payload
490 }
491}