@holoscript/framework 6.0.3
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/ALL-test-results.json +1 -0
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/ROADMAP.md +175 -0
- package/dist/AgentManifest-CB4xM-Ma.d.cts +704 -0
- package/dist/AgentManifest-CB4xM-Ma.d.ts +704 -0
- package/dist/BehaviorTree-BrBFECv5.d.cts +103 -0
- package/dist/BehaviorTree-BrBFECv5.d.ts +103 -0
- package/dist/InvisibleWallet-BB6tFvRA.d.cts +1732 -0
- package/dist/InvisibleWallet-rtRrBOA8.d.ts +1732 -0
- package/dist/OrchestratorAgent-BvWgf9uw.d.cts +798 -0
- package/dist/OrchestratorAgent-Q_CbVTmO.d.ts +798 -0
- package/dist/agents/index.cjs +4790 -0
- package/dist/agents/index.d.cts +1788 -0
- package/dist/agents/index.d.ts +1788 -0
- package/dist/agents/index.js +4695 -0
- package/dist/ai/index.cjs +5347 -0
- package/dist/ai/index.d.cts +1753 -0
- package/dist/ai/index.d.ts +1753 -0
- package/dist/ai/index.js +5244 -0
- package/dist/behavior.cjs +449 -0
- package/dist/behavior.d.cts +130 -0
- package/dist/behavior.d.ts +130 -0
- package/dist/behavior.js +407 -0
- package/dist/economy/index.cjs +3659 -0
- package/dist/economy/index.d.cts +747 -0
- package/dist/economy/index.d.ts +747 -0
- package/dist/economy/index.js +3617 -0
- package/dist/implementations-D9T3un9D.d.cts +236 -0
- package/dist/implementations-D9T3un9D.d.ts +236 -0
- package/dist/index.cjs +24550 -0
- package/dist/index.d.cts +1729 -0
- package/dist/index.d.ts +1729 -0
- package/dist/index.js +24277 -0
- package/dist/learning/index.cjs +219 -0
- package/dist/learning/index.d.cts +104 -0
- package/dist/learning/index.d.ts +104 -0
- package/dist/learning/index.js +189 -0
- package/dist/negotiation/index.cjs +970 -0
- package/dist/negotiation/index.d.cts +610 -0
- package/dist/negotiation/index.d.ts +610 -0
- package/dist/negotiation/index.js +931 -0
- package/dist/skills/index.cjs +1118 -0
- package/dist/skills/index.d.cts +289 -0
- package/dist/skills/index.d.ts +289 -0
- package/dist/skills/index.js +1079 -0
- package/dist/swarm/index.cjs +5268 -0
- package/dist/swarm/index.d.cts +2433 -0
- package/dist/swarm/index.d.ts +2433 -0
- package/dist/swarm/index.js +5221 -0
- package/dist/training/index.cjs +2745 -0
- package/dist/training/index.d.cts +1734 -0
- package/dist/training/index.d.ts +1734 -0
- package/dist/training/index.js +2687 -0
- package/extract-failures.js +10 -0
- package/package.json +82 -0
- package/src/__tests__/bounty-marketplace.test.ts +374 -0
- package/src/__tests__/delegation.test.ts +144 -0
- package/src/__tests__/distributed-claimer.test.ts +147 -0
- package/src/__tests__/done-log-audit.test.ts +342 -0
- package/src/__tests__/framework.test.ts +865 -0
- package/src/__tests__/goal-synthesizer.test.ts +236 -0
- package/src/__tests__/presence.test.ts +223 -0
- package/src/__tests__/protocol-agent.test.ts +254 -0
- package/src/__tests__/revenue-splitter.test.ts +114 -0
- package/src/__tests__/scenario-driven-todo.test.ts +197 -0
- package/src/__tests__/self-improve.test.ts +349 -0
- package/src/__tests__/service-lifecycle.test.ts +237 -0
- package/src/__tests__/skill-router.test.ts +121 -0
- package/src/agents/AgentManifest.ts +493 -0
- package/src/agents/AgentRegistry.ts +475 -0
- package/src/agents/AgentTypes.ts +585 -0
- package/src/agents/AgentWalletRegistry.ts +83 -0
- package/src/agents/AuthenticatedCRDT.ts +388 -0
- package/src/agents/CapabilityMatcher.ts +453 -0
- package/src/agents/CrossRealityHandoff.ts +305 -0
- package/src/agents/CulturalMemory.ts +454 -0
- package/src/agents/FederatedRegistryAdapter.ts +429 -0
- package/src/agents/NormEngine.ts +450 -0
- package/src/agents/OrchestratorAgent.ts +414 -0
- package/src/agents/SkillWorkflowEngine.ts +472 -0
- package/src/agents/TaskDelegationService.ts +551 -0
- package/src/agents/__tests__/AgentManifest.prod.test.ts +134 -0
- package/src/agents/__tests__/AgentManifest.test.ts +182 -0
- package/src/agents/__tests__/AgentModule.test.ts +864 -0
- package/src/agents/__tests__/AgentRegistry.prod.test.ts +125 -0
- package/src/agents/__tests__/AgentRegistry.test.ts +148 -0
- package/src/agents/__tests__/AgentTypes.test.ts +534 -0
- package/src/agents/__tests__/AgentWalletRegistry.test.ts +152 -0
- package/src/agents/__tests__/AuthenticatedCRDT.test.ts +558 -0
- package/src/agents/__tests__/CapabilityMatcher.prod.test.ts +117 -0
- package/src/agents/__tests__/CapabilityMatcher.test.ts +178 -0
- package/src/agents/__tests__/CrossRealityHandoff.test.ts +402 -0
- package/src/agents/__tests__/CulturalMemory.test.ts +200 -0
- package/src/agents/__tests__/FederatedRegistryAdapter.test.ts +409 -0
- package/src/agents/__tests__/NormEngine.test.ts +276 -0
- package/src/agents/__tests__/OrchestratorAgent.test.ts +182 -0
- package/src/agents/__tests__/SkillWorkflowEngine.test.ts +357 -0
- package/src/agents/__tests__/TaskDelegationService.test.ts +446 -0
- package/src/agents/index.ts +107 -0
- package/src/agents/spatial-comms/Layer1RealTime.ts +621 -0
- package/src/agents/spatial-comms/Layer2A2A.ts +661 -0
- package/src/agents/spatial-comms/Layer3MCP.ts +651 -0
- package/src/agents/spatial-comms/ProtocolTypes.ts +543 -0
- package/src/agents/spatial-comms/SpatialCommClient.ts +483 -0
- package/src/agents/spatial-comms/__tests__/performance-benchmark.test.ts +465 -0
- package/src/agents/spatial-comms/examples/multi-agent-world-creation.ts +409 -0
- package/src/agents/spatial-comms/index.ts +66 -0
- package/src/ai/AIAdapter.ts +313 -0
- package/src/ai/AICopilot.ts +331 -0
- package/src/ai/AIOutputValidator.ts +203 -0
- package/src/ai/BTNodes.ts +239 -0
- package/src/ai/BehaviorSelector.ts +135 -0
- package/src/ai/BehaviorTree.ts +153 -0
- package/src/ai/Blackboard.ts +165 -0
- package/src/ai/GenerationAnalytics.ts +461 -0
- package/src/ai/GenerationCache.ts +265 -0
- package/src/ai/GoalPlanner.ts +165 -0
- package/src/ai/HoloScriptGenerator.ts +580 -0
- package/src/ai/InfluenceMap.ts +180 -0
- package/src/ai/NavMesh.ts +168 -0
- package/src/ai/PerceptionSystem.ts +178 -0
- package/src/ai/PromptTemplates.ts +453 -0
- package/src/ai/SemanticSearchService.ts +80 -0
- package/src/ai/StateMachine.ts +196 -0
- package/src/ai/SteeringBehavior.ts +150 -0
- package/src/ai/SteeringBehaviors.ts +244 -0
- package/src/ai/TrainingDataGenerator.ts +1082 -0
- package/src/ai/UtilityAI.ts +145 -0
- package/src/ai/__tests__/AIAdapter.prod.test.ts +259 -0
- package/src/ai/__tests__/AIAdapter.test.ts +109 -0
- package/src/ai/__tests__/AICopilot.prod.test.ts +341 -0
- package/src/ai/__tests__/AICopilot.test.ts +178 -0
- package/src/ai/__tests__/AIOutputValidator.prod.test.ts +226 -0
- package/src/ai/__tests__/AIOutputValidator.test.ts +138 -0
- package/src/ai/__tests__/BTNodes.prod.test.ts +391 -0
- package/src/ai/__tests__/BTNodes.test.ts +263 -0
- package/src/ai/__tests__/BehaviorSelector.prod.test.ts +129 -0
- package/src/ai/__tests__/BehaviorSelector.test.ts +132 -0
- package/src/ai/__tests__/BehaviorTree.prod.test.ts +266 -0
- package/src/ai/__tests__/BehaviorTree.test.ts +216 -0
- package/src/ai/__tests__/Blackboard.prod.test.ts +339 -0
- package/src/ai/__tests__/Blackboard.test.ts +183 -0
- package/src/ai/__tests__/GenerationAnalytics.prod.test.ts +141 -0
- package/src/ai/__tests__/GenerationAnalytics.test.ts +165 -0
- package/src/ai/__tests__/GenerationCache.prod.test.ts +144 -0
- package/src/ai/__tests__/GenerationCache.test.ts +171 -0
- package/src/ai/__tests__/GoalPlanner.prod.test.ts +189 -0
- package/src/ai/__tests__/GoalPlanner.test.ts +137 -0
- package/src/ai/__tests__/GoalPlannerDepth.prod.test.ts +217 -0
- package/src/ai/__tests__/HoloScriptGenerator.test.ts +125 -0
- package/src/ai/__tests__/InfluenceMap.prod.test.ts +146 -0
- package/src/ai/__tests__/InfluenceMap.test.ts +149 -0
- package/src/ai/__tests__/NavMesh.prod.test.ts +141 -0
- package/src/ai/__tests__/NavMesh.test.ts +159 -0
- package/src/ai/__tests__/PerceptionSystem.prod.test.ts +135 -0
- package/src/ai/__tests__/PerceptionSystem.test.ts +250 -0
- package/src/ai/__tests__/PromptTemplates.prod.test.ts +313 -0
- package/src/ai/__tests__/PromptTemplates.test.ts +146 -0
- package/src/ai/__tests__/SemanticSearch.test.ts +37 -0
- package/src/ai/__tests__/StateMachine.prod.test.ts +162 -0
- package/src/ai/__tests__/StateMachine.test.ts +163 -0
- package/src/ai/__tests__/SteeringBehavior.prod.test.ts +251 -0
- package/src/ai/__tests__/SteeringBehavior.test.ts +135 -0
- package/src/ai/__tests__/SteeringBehaviors.prod.test.ts +133 -0
- package/src/ai/__tests__/SteeringBehaviors.test.ts +151 -0
- package/src/ai/__tests__/TrainingDataGenerator.prod.test.ts +286 -0
- package/src/ai/__tests__/TrainingDataGenerator.test.ts +286 -0
- package/src/ai/__tests__/UtilityAI.prod.test.ts +207 -0
- package/src/ai/__tests__/UtilityAI.test.ts +155 -0
- package/src/ai/__tests__/adapters.prod.test.ts +263 -0
- package/src/ai/__tests__/adapters.test.ts +320 -0
- package/src/ai/adapters.ts +1585 -0
- package/src/ai/index.ts +130 -0
- package/src/behavior/BehaviorPresets.ts +140 -0
- package/src/behavior/BehaviorTree.ts +236 -0
- package/src/behavior/StateMachine.ts +176 -0
- package/src/behavior/StateTrait.ts +67 -0
- package/src/behavior/index.ts +8 -0
- package/src/behavior.ts +8 -0
- package/src/board/audit.ts +284 -0
- package/src/board/board-ops.ts +336 -0
- package/src/board/board-types.ts +302 -0
- package/src/board/index.ts +69 -0
- package/src/define-agent.ts +46 -0
- package/src/define-team.ts +33 -0
- package/src/delegation.ts +265 -0
- package/src/distributed-claimer.ts +228 -0
- package/src/economy/AgentBudgetEnforcer.ts +464 -0
- package/src/economy/BountyManager.ts +185 -0
- package/src/economy/CreatorRevenueAggregator.ts +460 -0
- package/src/economy/InvisibleWallet.ts +82 -0
- package/src/economy/KnowledgeMarketplace.ts +193 -0
- package/src/economy/PaymentWebhookService.ts +512 -0
- package/src/economy/RevenueSplitter.ts +156 -0
- package/src/economy/SubscriptionManager.ts +546 -0
- package/src/economy/UnifiedBudgetOptimizer.ts +635 -0
- package/src/economy/UsageMeter.ts +440 -0
- package/src/economy/_core-stubs.ts +219 -0
- package/src/economy/index.ts +100 -0
- package/src/economy/x402-facilitator.ts +1978 -0
- package/src/index.ts +348 -0
- package/src/knowledge/__tests__/knowledge-consolidator.test.ts +444 -0
- package/src/knowledge/__tests__/knowledge-store-vector.test.ts +291 -0
- package/src/knowledge/brain.ts +167 -0
- package/src/knowledge/consolidation.ts +581 -0
- package/src/knowledge/knowledge-consolidator.ts +510 -0
- package/src/knowledge/knowledge-store.ts +616 -0
- package/src/learning/MemoryConsolidator.ts +102 -0
- package/src/learning/MemoryScorer.ts +69 -0
- package/src/learning/ProceduralCompiler.ts +45 -0
- package/src/learning/SemanticClusterer.ts +66 -0
- package/src/learning/index.ts +8 -0
- package/src/llm/llm-adapter.ts +159 -0
- package/src/mesh/index.ts +309 -0
- package/src/negotiation/NegotiationProtocol.ts +694 -0
- package/src/negotiation/NegotiationTypes.ts +473 -0
- package/src/negotiation/VotingMechanisms.ts +691 -0
- package/src/negotiation/index.ts +49 -0
- package/src/protocol/goal-synthesizer.ts +317 -0
- package/src/protocol/implementations.ts +474 -0
- package/src/protocol/micro-phase-decomposer.ts +299 -0
- package/src/protocol/micro-step-decomposer.test.ts +306 -0
- package/src/protocol-agent.test.ts +353 -0
- package/src/protocol-agent.ts +670 -0
- package/src/self-improve/absorb-scanner.ts +252 -0
- package/src/self-improve/evolution-engine.ts +149 -0
- package/src/self-improve/framework-absorber.ts +214 -0
- package/src/self-improve/index.ts +50 -0
- package/src/self-improve/prompt-optimizer.ts +212 -0
- package/src/self-improve/test-generator.ts +175 -0
- package/src/skill-router.ts +186 -0
- package/src/skills/index.ts +5 -0
- package/src/skills/skill-md-bridge.ts +1699 -0
- package/src/swarm/ACOEngine.ts +261 -0
- package/src/swarm/CollectiveIntelligence.ts +383 -0
- package/src/swarm/ContributionSynthesizer.ts +481 -0
- package/src/swarm/LeaderElection.ts +393 -0
- package/src/swarm/PSOEngine.ts +206 -0
- package/src/swarm/QuorumPolicy.ts +173 -0
- package/src/swarm/SwarmCoordinator.ts +335 -0
- package/src/swarm/SwarmManager.ts +442 -0
- package/src/swarm/SwarmMembership.ts +456 -0
- package/src/swarm/VotingRound.ts +255 -0
- package/src/swarm/__tests__/ACOEngine.prod.test.ts +164 -0
- package/src/swarm/__tests__/ACOEngine.test.ts +117 -0
- package/src/swarm/__tests__/CollectiveIntelligence.prod.test.ts +296 -0
- package/src/swarm/__tests__/CollectiveIntelligence.test.ts +457 -0
- package/src/swarm/__tests__/ContributionSynthesizer.prod.test.ts +269 -0
- package/src/swarm/__tests__/ContributionSynthesizer.test.ts +254 -0
- package/src/swarm/__tests__/LeaderElection.prod.test.ts +196 -0
- package/src/swarm/__tests__/LeaderElection.test.ts +151 -0
- package/src/swarm/__tests__/PSOEngine.prod.test.ts +162 -0
- package/src/swarm/__tests__/PSOEngine.test.ts +106 -0
- package/src/swarm/__tests__/QuorumPolicy.prod.test.ts +216 -0
- package/src/swarm/__tests__/QuorumPolicy.test.ts +177 -0
- package/src/swarm/__tests__/SwarmCoordinator.prod.test.ts +186 -0
- package/src/swarm/__tests__/SwarmCoordinator.test.ts +167 -0
- package/src/swarm/__tests__/SwarmManager.prod.test.ts +308 -0
- package/src/swarm/__tests__/SwarmManager.test.ts +373 -0
- package/src/swarm/__tests__/SwarmMembership.prod.test.ts +273 -0
- package/src/swarm/__tests__/SwarmMembership.test.ts +264 -0
- package/src/swarm/__tests__/VotingRound.prod.test.ts +233 -0
- package/src/swarm/__tests__/VotingRound.test.ts +174 -0
- package/src/swarm/analytics/SwarmInspector.ts +476 -0
- package/src/swarm/analytics/SwarmMetrics.ts +449 -0
- package/src/swarm/analytics/__tests__/SwarmInspector.prod.test.ts +366 -0
- package/src/swarm/analytics/__tests__/SwarmInspector.test.ts +454 -0
- package/src/swarm/analytics/__tests__/SwarmMetrics.prod.test.ts +254 -0
- package/src/swarm/analytics/__tests__/SwarmMetrics.test.ts +370 -0
- package/src/swarm/analytics/index.ts +7 -0
- package/src/swarm/index.ts +69 -0
- package/src/swarm/messaging/BroadcastChannel.ts +509 -0
- package/src/swarm/messaging/GossipProtocol.ts +565 -0
- package/src/swarm/messaging/SwarmEventBus.ts +443 -0
- package/src/swarm/messaging/__tests__/BroadcastChannel.prod.test.ts +331 -0
- package/src/swarm/messaging/__tests__/BroadcastChannel.test.ts +333 -0
- package/src/swarm/messaging/__tests__/GossipProtocol.prod.test.ts +356 -0
- package/src/swarm/messaging/__tests__/GossipProtocol.test.ts +437 -0
- package/src/swarm/messaging/__tests__/SwarmEventBus.prod.test.ts +191 -0
- package/src/swarm/messaging/__tests__/SwarmEventBus.test.ts +247 -0
- package/src/swarm/messaging/index.ts +8 -0
- package/src/swarm/spatial/FlockingBehavior.ts +462 -0
- package/src/swarm/spatial/FormationController.ts +500 -0
- package/src/swarm/spatial/Vector3.ts +170 -0
- package/src/swarm/spatial/ZoneClaiming.ts +509 -0
- package/src/swarm/spatial/__tests__/FlockingBehavior.prod.test.ts +239 -0
- package/src/swarm/spatial/__tests__/FlockingBehavior.test.ts +298 -0
- package/src/swarm/spatial/__tests__/FormationController.prod.test.ts +240 -0
- package/src/swarm/spatial/__tests__/FormationController.test.ts +297 -0
- package/src/swarm/spatial/__tests__/Vector3.prod.test.ts +283 -0
- package/src/swarm/spatial/__tests__/Vector3.test.ts +224 -0
- package/src/swarm/spatial/__tests__/ZoneClaiming.prod.test.ts +246 -0
- package/src/swarm/spatial/__tests__/ZoneClaiming.test.ts +374 -0
- package/src/swarm/spatial/index.ts +28 -0
- package/src/team.ts +1245 -0
- package/src/training/LRScheduler.ts +377 -0
- package/src/training/QualityScoringPipeline.ts +139 -0
- package/src/training/SoftDedup.ts +461 -0
- package/src/training/SparsityMonitor.ts +685 -0
- package/src/training/SparsityMonitorTypes.ts +209 -0
- package/src/training/SpatialTrainingDataGenerator.ts +1526 -0
- package/src/training/SpatialTrainingDataTypes.ts +216 -0
- package/src/training/TrainingPipelineConfig.ts +215 -0
- package/src/training/constants.ts +94 -0
- package/src/training/index.ts +138 -0
- package/src/training/schema.ts +147 -0
- package/src/training/scripts/generate-novel-use-cases-dataset.ts +272 -0
- package/src/training/scripts/generate-spatial-dataset.ts +521 -0
- package/src/training/training/data/novel-use-cases.jsonl +153 -0
- package/src/training/training/data/spatial-reasoning-10k.jsonl +9354 -0
- package/src/training/trainingmonkey/TrainingMonkeyIntegration.ts +477 -0
- package/src/training/trainingmonkey/TrainingMonkeyTypes.ts +230 -0
- package/src/training/trainingmonkey/index.ts +26 -0
- package/src/training/trait-mappings.ts +157 -0
- package/src/types/core-stubs.d.ts +113 -0
- package/src/types.ts +304 -0
- package/test-output.txt +0 -0
- package/test-result.json +1 -0
- package/tsc-errors.txt +4 -0
- package/tsc_output.txt +0 -0
- package/tsconfig.json +14 -0
- package/tsup-learning-esm.config.ts +12 -0
- package/tsup.config.ts +21 -0
- package/typescript-errors-2.txt +0 -0
- package/typescript-errors.txt +22 -0
- package/vitest-log-utf8.txt +268 -0
- package/vitest-log.txt +0 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,1978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 Payment Protocol Facilitator
|
|
3
|
+
*
|
|
4
|
+
* Implements the x402 protocol (https://www.x402.org/) for HTTP 402 Payment Required
|
|
5
|
+
* responses, enabling native internet payments for HoloScript resources.
|
|
6
|
+
*
|
|
7
|
+
* The x402 protocol flow:
|
|
8
|
+
* 1. Client requests a resource (GET /api/premium-scene)
|
|
9
|
+
* 2. Server responds with HTTP 402 + PaymentRequired body
|
|
10
|
+
* 3. Client signs an EIP-712 authorization (gasless) and retries with X-PAYMENT header
|
|
11
|
+
* 4. Facilitator verifies signature + settles on-chain
|
|
12
|
+
* 5. Server returns the resource with X-PAYMENT-RESPONSE confirmation
|
|
13
|
+
*
|
|
14
|
+
* Dual-mode settlement:
|
|
15
|
+
* - In-memory ledger for microtransactions < $0.10 (no gas, instant)
|
|
16
|
+
* - On-chain x402 settlement for amounts >= $0.10 (USDC on Base or Solana)
|
|
17
|
+
*
|
|
18
|
+
* Optimistic execution:
|
|
19
|
+
* - Proceeds on valid authorization before on-chain confirmation
|
|
20
|
+
* - Verifies settlement asynchronously, reverts on failure
|
|
21
|
+
*
|
|
22
|
+
* @version 1.0.0
|
|
23
|
+
* @see https://www.x402.org/
|
|
24
|
+
* @see https://docs.x402.org/
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Import real trait types from @holoscript/core
|
|
28
|
+
import type { HSPlusNode, TraitHandler, TraitContext, TraitEvent } from '@holoscript/core';
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// x402 PROTOCOL TYPES
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/** x402 protocol version */
|
|
35
|
+
export const X402_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/** Supported settlement chains */
|
|
38
|
+
export type SettlementChain = 'base' | 'base-sepolia' | 'solana' | 'solana-devnet';
|
|
39
|
+
|
|
40
|
+
/** Supported payment schemes per x402 spec */
|
|
41
|
+
export type PaymentScheme = 'exact';
|
|
42
|
+
|
|
43
|
+
/** Settlement mode based on transaction amount */
|
|
44
|
+
export type SettlementMode = 'in_memory' | 'on_chain';
|
|
45
|
+
|
|
46
|
+
/** USDC contract addresses per chain */
|
|
47
|
+
export const USDC_CONTRACTS: Record<SettlementChain, string> = {
|
|
48
|
+
base: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
49
|
+
'base-sepolia': '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
|
|
50
|
+
solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
|
51
|
+
'solana-devnet': '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Threshold for switching from in-memory to on-chain settlement (in USDC base units, 6 decimals) */
|
|
55
|
+
export const MICRO_PAYMENT_THRESHOLD = 100_000; // 0.10 USDC (6 decimals)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* x402 PaymentRequired response body.
|
|
59
|
+
* Returned as HTTP 402 response when payment is needed.
|
|
60
|
+
*/
|
|
61
|
+
export interface X402PaymentRequired {
|
|
62
|
+
/** Protocol version */
|
|
63
|
+
x402Version: number;
|
|
64
|
+
/** Accepted payment methods */
|
|
65
|
+
accepts: X402PaymentOption[];
|
|
66
|
+
/** Human-readable error description */
|
|
67
|
+
error: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A single payment option within the accepts array.
|
|
72
|
+
*/
|
|
73
|
+
export interface X402PaymentOption {
|
|
74
|
+
/** Payment model: "exact" (fixed price) */
|
|
75
|
+
scheme: PaymentScheme;
|
|
76
|
+
/** Blockchain network identifier */
|
|
77
|
+
network: SettlementChain;
|
|
78
|
+
/** Amount in token base units (string for precision, USDC = 6 decimals) */
|
|
79
|
+
maxAmountRequired: string;
|
|
80
|
+
/** The resource being paid for */
|
|
81
|
+
resource: string;
|
|
82
|
+
/** Human-readable description */
|
|
83
|
+
description: string;
|
|
84
|
+
/** Recipient wallet address */
|
|
85
|
+
payTo: string;
|
|
86
|
+
/** Token contract address (USDC) */
|
|
87
|
+
asset: string;
|
|
88
|
+
/** Maximum seconds to complete payment */
|
|
89
|
+
maxTimeoutSeconds: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* EIP-712 signed authorization payload from the client.
|
|
94
|
+
* Sent in the X-PAYMENT header (base64-encoded).
|
|
95
|
+
*/
|
|
96
|
+
export interface X402PaymentPayload {
|
|
97
|
+
/** Protocol version */
|
|
98
|
+
x402Version: number;
|
|
99
|
+
/** Payment scheme */
|
|
100
|
+
scheme: PaymentScheme;
|
|
101
|
+
/** Target network */
|
|
102
|
+
network: SettlementChain;
|
|
103
|
+
/** Signed authorization */
|
|
104
|
+
payload: {
|
|
105
|
+
/** EIP-712 or Ed25519 signature */
|
|
106
|
+
signature: string;
|
|
107
|
+
/** Transfer authorization details */
|
|
108
|
+
authorization: {
|
|
109
|
+
/** Payer address */
|
|
110
|
+
from: string;
|
|
111
|
+
/** Recipient address */
|
|
112
|
+
to: string;
|
|
113
|
+
/** Amount in base units */
|
|
114
|
+
value: string;
|
|
115
|
+
/** Unix timestamp after which authorization is valid */
|
|
116
|
+
validAfter: string;
|
|
117
|
+
/** Unix timestamp before which authorization is valid */
|
|
118
|
+
validBefore: string;
|
|
119
|
+
/** Unique nonce to prevent replay */
|
|
120
|
+
nonce: string;
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Settlement result after on-chain or in-memory settlement.
|
|
127
|
+
*/
|
|
128
|
+
export interface X402SettlementResult {
|
|
129
|
+
/** Whether settlement succeeded */
|
|
130
|
+
success: boolean;
|
|
131
|
+
/** On-chain transaction hash (null for in-memory) */
|
|
132
|
+
transaction: string | null;
|
|
133
|
+
/** Network where settlement occurred */
|
|
134
|
+
network: SettlementChain | 'in_memory';
|
|
135
|
+
/** Payer address or ID */
|
|
136
|
+
payer: string;
|
|
137
|
+
/** Error reason if failed */
|
|
138
|
+
errorReason: string | null;
|
|
139
|
+
/** Settlement mode used */
|
|
140
|
+
mode: SettlementMode;
|
|
141
|
+
/** Timestamp of settlement */
|
|
142
|
+
settledAt: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verification result from the facilitator.
|
|
147
|
+
*/
|
|
148
|
+
export interface X402VerificationResult {
|
|
149
|
+
/** Whether the payment authorization is valid */
|
|
150
|
+
isValid: boolean;
|
|
151
|
+
/** Reason for invalidity */
|
|
152
|
+
invalidReason: string | null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* In-memory ledger entry for micro-payments.
|
|
157
|
+
*/
|
|
158
|
+
export interface LedgerEntry {
|
|
159
|
+
/** Unique transaction ID */
|
|
160
|
+
id: string;
|
|
161
|
+
/** Payer identifier */
|
|
162
|
+
from: string;
|
|
163
|
+
/** Recipient identifier */
|
|
164
|
+
to: string;
|
|
165
|
+
/** Amount in USDC base units (6 decimals) */
|
|
166
|
+
amount: number;
|
|
167
|
+
/** Resource accessed */
|
|
168
|
+
resource: string;
|
|
169
|
+
/** Timestamp */
|
|
170
|
+
timestamp: number;
|
|
171
|
+
/** Whether this has been settled on-chain (batch settlement) */
|
|
172
|
+
settled: boolean;
|
|
173
|
+
/** On-chain tx hash after batch settlement */
|
|
174
|
+
settlementTx: string | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Configuration for the x402 facilitator.
|
|
179
|
+
*/
|
|
180
|
+
export interface X402FacilitatorConfig {
|
|
181
|
+
/** Recipient wallet address for payments */
|
|
182
|
+
recipientAddress: string;
|
|
183
|
+
/** Primary settlement chain */
|
|
184
|
+
chain: SettlementChain;
|
|
185
|
+
/** Secondary chain (optional, for multi-chain support) */
|
|
186
|
+
secondaryChain?: SettlementChain;
|
|
187
|
+
/** Micro-payment threshold in USDC base units (default: 100000 = $0.10) */
|
|
188
|
+
microPaymentThreshold?: number;
|
|
189
|
+
/** Maximum timeout for payment completion in seconds */
|
|
190
|
+
maxTimeoutSeconds?: number;
|
|
191
|
+
/** Enable optimistic execution (proceed before on-chain confirmation) */
|
|
192
|
+
optimisticExecution?: boolean;
|
|
193
|
+
/** Batch settlement interval for in-memory ledger entries (ms) */
|
|
194
|
+
batchSettlementIntervalMs?: number;
|
|
195
|
+
/** Maximum in-memory ledger entries before forced batch settlement */
|
|
196
|
+
maxLedgerEntries?: number;
|
|
197
|
+
/** Facilitator service URL for on-chain verification/settlement */
|
|
198
|
+
facilitatorUrl?: string;
|
|
199
|
+
/** Resource description template */
|
|
200
|
+
resourceDescription?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// =============================================================================
|
|
204
|
+
// IN-MEMORY MICRO-PAYMENT LEDGER
|
|
205
|
+
// =============================================================================
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* In-memory ledger for tracking micro-payments below the on-chain threshold.
|
|
209
|
+
* Entries accumulate and are batch-settled periodically.
|
|
210
|
+
*
|
|
211
|
+
* Thread-safe via synchronous JS execution model.
|
|
212
|
+
*/
|
|
213
|
+
export class MicroPaymentLedger {
|
|
214
|
+
private entries: LedgerEntry[] = [];
|
|
215
|
+
private balances: Map<string, number> = new Map();
|
|
216
|
+
private txCounter = 0;
|
|
217
|
+
private readonly maxEntries: number;
|
|
218
|
+
|
|
219
|
+
constructor(maxEntries = 10_000) {
|
|
220
|
+
this.maxEntries = maxEntries;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Record a micro-payment in the in-memory ledger.
|
|
225
|
+
*/
|
|
226
|
+
record(from: string, to: string, amount: number, resource: string): LedgerEntry {
|
|
227
|
+
const entry: LedgerEntry = {
|
|
228
|
+
id: `micro_${Date.now()}_${this.txCounter++}`,
|
|
229
|
+
from,
|
|
230
|
+
to,
|
|
231
|
+
amount,
|
|
232
|
+
resource,
|
|
233
|
+
timestamp: Date.now(),
|
|
234
|
+
settled: false,
|
|
235
|
+
settlementTx: null,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
this.entries.push(entry);
|
|
239
|
+
|
|
240
|
+
// Update balances
|
|
241
|
+
const fromBalance = this.balances.get(from) ?? 0;
|
|
242
|
+
this.balances.set(from, fromBalance - amount);
|
|
243
|
+
|
|
244
|
+
const toBalance = this.balances.get(to) ?? 0;
|
|
245
|
+
this.balances.set(to, toBalance + amount);
|
|
246
|
+
|
|
247
|
+
// Trim if over limit
|
|
248
|
+
if (this.entries.length > this.maxEntries) {
|
|
249
|
+
this.entries = this.entries.slice(-this.maxEntries);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return entry;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get unsettled entries for batch settlement.
|
|
257
|
+
*/
|
|
258
|
+
getUnsettled(): LedgerEntry[] {
|
|
259
|
+
return this.entries.filter((e) => !e.settled);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Mark entries as settled after batch on-chain settlement.
|
|
264
|
+
*/
|
|
265
|
+
markSettled(entryIds: string[], txHash: string): void {
|
|
266
|
+
for (const entry of this.entries) {
|
|
267
|
+
if (entryIds.includes(entry.id)) {
|
|
268
|
+
entry.settled = true;
|
|
269
|
+
entry.settlementTx = txHash;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get the net balance for an address (can be negative = owes).
|
|
276
|
+
*/
|
|
277
|
+
getBalance(address: string): number {
|
|
278
|
+
return this.balances.get(address) ?? 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get total unsettled volume.
|
|
283
|
+
*/
|
|
284
|
+
getUnsettledVolume(): number {
|
|
285
|
+
return this.getUnsettled().reduce((sum, e) => sum + e.amount, 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get all entries for a specific payer.
|
|
290
|
+
*/
|
|
291
|
+
getEntriesForPayer(from: string): LedgerEntry[] {
|
|
292
|
+
return this.entries.filter((e) => e.from === from);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get ledger statistics.
|
|
297
|
+
*/
|
|
298
|
+
getStats(): {
|
|
299
|
+
totalEntries: number;
|
|
300
|
+
unsettledEntries: number;
|
|
301
|
+
unsettledVolume: number;
|
|
302
|
+
uniquePayers: number;
|
|
303
|
+
uniqueRecipients: number;
|
|
304
|
+
} {
|
|
305
|
+
const unsettled = this.getUnsettled();
|
|
306
|
+
const payers = new Set(this.entries.map((e) => e.from));
|
|
307
|
+
const recipients = new Set(this.entries.map((e) => e.to));
|
|
308
|
+
return {
|
|
309
|
+
totalEntries: this.entries.length,
|
|
310
|
+
unsettledEntries: unsettled.length,
|
|
311
|
+
unsettledVolume: unsettled.reduce((sum, e) => sum + e.amount, 0),
|
|
312
|
+
uniquePayers: payers.size,
|
|
313
|
+
uniqueRecipients: recipients.size,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Clear all settled entries (garbage collection).
|
|
319
|
+
*/
|
|
320
|
+
pruneSettled(): number {
|
|
321
|
+
const before = this.entries.length;
|
|
322
|
+
this.entries = this.entries.filter((e) => !e.settled);
|
|
323
|
+
return before - this.entries.length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Reset the entire ledger.
|
|
328
|
+
*/
|
|
329
|
+
reset(): void {
|
|
330
|
+
this.entries = [];
|
|
331
|
+
this.balances.clear();
|
|
332
|
+
this.txCounter = 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// x402 FACILITATOR
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* x402 Payment Protocol Facilitator
|
|
342
|
+
*
|
|
343
|
+
* Central coordinator for x402 payment flows in HoloScript. Manages:
|
|
344
|
+
* - PaymentRequired response generation for @credit-gated resources
|
|
345
|
+
* - Payment verification (signature + authorization validity)
|
|
346
|
+
* - Dual-mode settlement (in-memory micro vs on-chain macro)
|
|
347
|
+
* - Optimistic execution with async settlement verification
|
|
348
|
+
* - Batch settlement of accumulated micro-payments
|
|
349
|
+
*
|
|
350
|
+
* Security considerations:
|
|
351
|
+
* - All signatures are verified before granting access
|
|
352
|
+
* - Nonce tracking prevents replay attacks
|
|
353
|
+
* - ValidBefore/ValidAfter windowing prevents stale authorizations
|
|
354
|
+
* - In-memory ledger has hard caps to prevent memory exhaustion
|
|
355
|
+
* - Optimistic execution only for amounts below configurable threshold
|
|
356
|
+
*/
|
|
357
|
+
export class X402Facilitator {
|
|
358
|
+
private config: Required<X402FacilitatorConfig>;
|
|
359
|
+
private ledger: MicroPaymentLedger;
|
|
360
|
+
private usedNonces: Set<string> = new Set();
|
|
361
|
+
private pendingSettlements: Map<string, X402PaymentPayload> = new Map();
|
|
362
|
+
private settlementResults: Map<string, X402SettlementResult> = new Map();
|
|
363
|
+
private batchSettlementTimer: ReturnType<typeof setInterval> | null = null;
|
|
364
|
+
|
|
365
|
+
constructor(config: X402FacilitatorConfig) {
|
|
366
|
+
this.config = {
|
|
367
|
+
recipientAddress: config.recipientAddress,
|
|
368
|
+
chain: config.chain,
|
|
369
|
+
secondaryChain: config.secondaryChain ?? config.chain,
|
|
370
|
+
microPaymentThreshold: config.microPaymentThreshold ?? MICRO_PAYMENT_THRESHOLD,
|
|
371
|
+
maxTimeoutSeconds: config.maxTimeoutSeconds ?? 60,
|
|
372
|
+
optimisticExecution: config.optimisticExecution ?? true,
|
|
373
|
+
batchSettlementIntervalMs: config.batchSettlementIntervalMs ?? 300_000, // 5 min
|
|
374
|
+
maxLedgerEntries: config.maxLedgerEntries ?? 10_000,
|
|
375
|
+
facilitatorUrl: config.facilitatorUrl ?? 'https://x402.org/facilitator',
|
|
376
|
+
resourceDescription: config.resourceDescription ?? 'HoloScript premium resource',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
this.ledger = new MicroPaymentLedger(this.config.maxLedgerEntries);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ===========================================================================
|
|
383
|
+
// PAYMENT REQUIRED RESPONSE GENERATION
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Generate an HTTP 402 PaymentRequired response body.
|
|
388
|
+
*
|
|
389
|
+
* @param resource - The resource path being requested (e.g., "/api/scene/premium")
|
|
390
|
+
* @param amountUSDC - Price in USDC (human-readable, e.g., 0.05 for 5 cents)
|
|
391
|
+
* @param description - Human-readable description of what is being paid for
|
|
392
|
+
* @returns PaymentRequired response body conforming to x402 spec
|
|
393
|
+
*/
|
|
394
|
+
createPaymentRequired(
|
|
395
|
+
resource: string,
|
|
396
|
+
amountUSDC: number,
|
|
397
|
+
description?: string
|
|
398
|
+
): X402PaymentRequired {
|
|
399
|
+
// Convert human-readable USDC to base units (6 decimals)
|
|
400
|
+
const baseUnits = Math.round(amountUSDC * 1_000_000).toString();
|
|
401
|
+
const desc = description ?? this.config.resourceDescription;
|
|
402
|
+
|
|
403
|
+
const accepts: X402PaymentOption[] = [
|
|
404
|
+
{
|
|
405
|
+
scheme: 'exact',
|
|
406
|
+
network: this.config.chain,
|
|
407
|
+
maxAmountRequired: baseUnits,
|
|
408
|
+
resource,
|
|
409
|
+
description: desc,
|
|
410
|
+
payTo: this.config.recipientAddress,
|
|
411
|
+
asset: USDC_CONTRACTS[this.config.chain],
|
|
412
|
+
maxTimeoutSeconds: this.config.maxTimeoutSeconds,
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
// Add secondary chain option if different
|
|
417
|
+
if (this.config.secondaryChain !== this.config.chain) {
|
|
418
|
+
accepts.push({
|
|
419
|
+
scheme: 'exact',
|
|
420
|
+
network: this.config.secondaryChain,
|
|
421
|
+
maxAmountRequired: baseUnits,
|
|
422
|
+
resource,
|
|
423
|
+
description: desc,
|
|
424
|
+
payTo: this.config.recipientAddress,
|
|
425
|
+
asset: USDC_CONTRACTS[this.config.secondaryChain],
|
|
426
|
+
maxTimeoutSeconds: this.config.maxTimeoutSeconds,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
x402Version: X402_VERSION,
|
|
432
|
+
accepts,
|
|
433
|
+
error: 'X-PAYMENT header is required',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ===========================================================================
|
|
438
|
+
// PAYMENT VERIFICATION
|
|
439
|
+
// ===========================================================================
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Verify an X-PAYMENT header payload.
|
|
443
|
+
*
|
|
444
|
+
* Checks:
|
|
445
|
+
* 1. Protocol version matches
|
|
446
|
+
* 2. Nonce has not been used (replay protection)
|
|
447
|
+
* 3. Authorization window is valid (validAfter <= now <= validBefore)
|
|
448
|
+
* 4. Payment amount matches or exceeds required amount
|
|
449
|
+
* 5. Recipient matches configured address
|
|
450
|
+
* 6. Network is supported
|
|
451
|
+
*
|
|
452
|
+
* NOTE: Signature cryptographic verification requires on-chain verification
|
|
453
|
+
* via the facilitator service. This method validates the structural/temporal
|
|
454
|
+
* aspects. For full verification including signature, use verifyAndSettle().
|
|
455
|
+
*
|
|
456
|
+
* @param payment - Decoded X-PAYMENT payload
|
|
457
|
+
* @param requiredAmount - Minimum amount in USDC base units
|
|
458
|
+
* @returns Verification result
|
|
459
|
+
*/
|
|
460
|
+
verifyPayment(payment: X402PaymentPayload, requiredAmount: string): X402VerificationResult {
|
|
461
|
+
// Check protocol version
|
|
462
|
+
if (payment.x402Version !== X402_VERSION) {
|
|
463
|
+
return { isValid: false, invalidReason: `Unsupported x402 version: ${payment.x402Version}` };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Check scheme
|
|
467
|
+
if (payment.scheme !== 'exact') {
|
|
468
|
+
return { isValid: false, invalidReason: `Unsupported scheme: ${payment.scheme}` };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Check network
|
|
472
|
+
if (!USDC_CONTRACTS[payment.network]) {
|
|
473
|
+
return { isValid: false, invalidReason: `Unsupported network: ${payment.network}` };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Check nonce (replay protection)
|
|
477
|
+
const nonce = payment.payload.authorization.nonce;
|
|
478
|
+
if (this.usedNonces.has(nonce)) {
|
|
479
|
+
return { isValid: false, invalidReason: 'Nonce already used (replay attack prevented)' };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Check authorization window
|
|
483
|
+
const now = Math.floor(Date.now() / 1000);
|
|
484
|
+
const validAfter = parseInt(payment.payload.authorization.validAfter, 10);
|
|
485
|
+
const validBefore = parseInt(payment.payload.authorization.validBefore, 10);
|
|
486
|
+
|
|
487
|
+
if (now < validAfter) {
|
|
488
|
+
return {
|
|
489
|
+
isValid: false,
|
|
490
|
+
invalidReason: 'Authorization not yet valid (validAfter in future)',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (now > validBefore) {
|
|
494
|
+
return { isValid: false, invalidReason: 'Authorization expired (validBefore in past)' };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Check amount
|
|
498
|
+
const paymentAmount = BigInt(payment.payload.authorization.value);
|
|
499
|
+
const required = BigInt(requiredAmount);
|
|
500
|
+
if (paymentAmount < required) {
|
|
501
|
+
return {
|
|
502
|
+
isValid: false,
|
|
503
|
+
invalidReason: `Insufficient payment: ${paymentAmount} < ${required}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Check recipient
|
|
508
|
+
const payTo = payment.payload.authorization.to.toLowerCase();
|
|
509
|
+
const configRecipient = this.config.recipientAddress.toLowerCase();
|
|
510
|
+
if (payTo !== configRecipient) {
|
|
511
|
+
return {
|
|
512
|
+
isValid: false,
|
|
513
|
+
invalidReason: `Recipient mismatch: ${payTo} !== ${configRecipient}`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check signature is present
|
|
518
|
+
if (!payment.payload.signature || payment.payload.signature.length < 10) {
|
|
519
|
+
return { isValid: false, invalidReason: 'Missing or invalid signature' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { isValid: true, invalidReason: null };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ===========================================================================
|
|
526
|
+
// DUAL-MODE SETTLEMENT
|
|
527
|
+
// ===========================================================================
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Determine the settlement mode based on the payment amount.
|
|
531
|
+
*
|
|
532
|
+
* @param amountBaseUnits - Amount in USDC base units (6 decimals)
|
|
533
|
+
* @returns Settlement mode
|
|
534
|
+
*/
|
|
535
|
+
getSettlementMode(amountBaseUnits: number): SettlementMode {
|
|
536
|
+
return amountBaseUnits < this.config.microPaymentThreshold ? 'in_memory' : 'on_chain';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Process a payment with dual-mode settlement.
|
|
541
|
+
*
|
|
542
|
+
* For micro-payments (< threshold):
|
|
543
|
+
* - Records in in-memory ledger immediately
|
|
544
|
+
* - Returns instant success
|
|
545
|
+
* - Batch settles periodically
|
|
546
|
+
*
|
|
547
|
+
* For macro-payments (>= threshold):
|
|
548
|
+
* - If optimistic execution enabled: grants access immediately, settles async
|
|
549
|
+
* - If not: waits for settlement before granting access
|
|
550
|
+
*
|
|
551
|
+
* @param payment - Decoded X-PAYMENT payload
|
|
552
|
+
* @param resource - Resource being accessed
|
|
553
|
+
* @param requiredAmount - Required amount in USDC base units
|
|
554
|
+
* @returns Settlement result
|
|
555
|
+
*/
|
|
556
|
+
async processPayment(
|
|
557
|
+
payment: X402PaymentPayload,
|
|
558
|
+
resource: string,
|
|
559
|
+
requiredAmount: string
|
|
560
|
+
): Promise<X402SettlementResult> {
|
|
561
|
+
// Step 1: Verify the payment
|
|
562
|
+
const verification = this.verifyPayment(payment, requiredAmount);
|
|
563
|
+
if (!verification.isValid) {
|
|
564
|
+
return {
|
|
565
|
+
success: false,
|
|
566
|
+
transaction: null,
|
|
567
|
+
network: payment.network,
|
|
568
|
+
payer: payment.payload.authorization.from,
|
|
569
|
+
errorReason: verification.invalidReason,
|
|
570
|
+
mode: 'on_chain',
|
|
571
|
+
settledAt: Date.now(),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Mark nonce as used
|
|
576
|
+
this.usedNonces.add(payment.payload.authorization.nonce);
|
|
577
|
+
|
|
578
|
+
const amount = parseInt(payment.payload.authorization.value, 10);
|
|
579
|
+
const mode = this.getSettlementMode(amount);
|
|
580
|
+
|
|
581
|
+
if (mode === 'in_memory') {
|
|
582
|
+
return this.settleMicroPayment(payment, resource, amount);
|
|
583
|
+
} else {
|
|
584
|
+
return this.settleOnChain(payment, resource, requiredAmount);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Settle a micro-payment in the in-memory ledger.
|
|
590
|
+
*/
|
|
591
|
+
private settleMicroPayment(
|
|
592
|
+
payment: X402PaymentPayload,
|
|
593
|
+
resource: string,
|
|
594
|
+
amount: number
|
|
595
|
+
): X402SettlementResult {
|
|
596
|
+
const entry = this.ledger.record(
|
|
597
|
+
payment.payload.authorization.from,
|
|
598
|
+
payment.payload.authorization.to,
|
|
599
|
+
amount,
|
|
600
|
+
resource
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
success: true,
|
|
605
|
+
transaction: entry.id, // In-memory tx ID
|
|
606
|
+
network: 'in_memory',
|
|
607
|
+
payer: payment.payload.authorization.from,
|
|
608
|
+
errorReason: null,
|
|
609
|
+
mode: 'in_memory',
|
|
610
|
+
settledAt: Date.now(),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Settle a payment on-chain via the facilitator service.
|
|
616
|
+
*
|
|
617
|
+
* In optimistic mode: returns success immediately, verifies async.
|
|
618
|
+
* In non-optimistic mode: waits for facilitator confirmation.
|
|
619
|
+
*/
|
|
620
|
+
private async settleOnChain(
|
|
621
|
+
payment: X402PaymentPayload,
|
|
622
|
+
_resource: string,
|
|
623
|
+
_requiredAmount: string
|
|
624
|
+
): Promise<X402SettlementResult> {
|
|
625
|
+
const payer = payment.payload.authorization.from;
|
|
626
|
+
const nonce = payment.payload.authorization.nonce;
|
|
627
|
+
|
|
628
|
+
if (this.config.optimisticExecution) {
|
|
629
|
+
// Optimistic: grant access now, verify async
|
|
630
|
+
this.pendingSettlements.set(nonce, payment);
|
|
631
|
+
|
|
632
|
+
// Fire-and-forget async settlement
|
|
633
|
+
this.verifySettlementAsync(payment).catch((err) => {
|
|
634
|
+
console.error('[x402] Async settlement verification failed:', err);
|
|
635
|
+
// Record failed settlement
|
|
636
|
+
this.settlementResults.set(nonce, {
|
|
637
|
+
success: false,
|
|
638
|
+
transaction: null,
|
|
639
|
+
network: payment.network,
|
|
640
|
+
payer,
|
|
641
|
+
errorReason: `Async verification failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
642
|
+
mode: 'on_chain',
|
|
643
|
+
settledAt: Date.now(),
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
success: true,
|
|
649
|
+
transaction: `pending_${nonce}`,
|
|
650
|
+
network: payment.network,
|
|
651
|
+
payer,
|
|
652
|
+
errorReason: null,
|
|
653
|
+
mode: 'on_chain',
|
|
654
|
+
settledAt: Date.now(),
|
|
655
|
+
};
|
|
656
|
+
} else {
|
|
657
|
+
// Non-optimistic: wait for facilitator
|
|
658
|
+
return this.verifySettlementSync(payment);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Verify settlement asynchronously (for optimistic execution).
|
|
664
|
+
* Calls the facilitator service to verify and execute the on-chain transfer.
|
|
665
|
+
*/
|
|
666
|
+
private async verifySettlementAsync(payment: X402PaymentPayload): Promise<void> {
|
|
667
|
+
const nonce = payment.payload.authorization.nonce;
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const result = await this.callFacilitator(payment);
|
|
671
|
+
this.settlementResults.set(nonce, result);
|
|
672
|
+
this.pendingSettlements.delete(nonce);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
this.settlementResults.set(nonce, {
|
|
675
|
+
success: false,
|
|
676
|
+
transaction: null,
|
|
677
|
+
network: payment.network,
|
|
678
|
+
payer: payment.payload.authorization.from,
|
|
679
|
+
errorReason: `Facilitator error: ${err instanceof Error ? err.message : String(err)}`,
|
|
680
|
+
mode: 'on_chain',
|
|
681
|
+
settledAt: Date.now(),
|
|
682
|
+
});
|
|
683
|
+
this.pendingSettlements.delete(nonce);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Verify settlement synchronously (blocking).
|
|
689
|
+
*/
|
|
690
|
+
private async verifySettlementSync(payment: X402PaymentPayload): Promise<X402SettlementResult> {
|
|
691
|
+
return this.callFacilitator(payment);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Call the x402 facilitator service for on-chain settlement.
|
|
696
|
+
*
|
|
697
|
+
* The facilitator:
|
|
698
|
+
* 1. Validates the EIP-712/Ed25519 signature cryptographically
|
|
699
|
+
* 2. Submits the `transferWithAuthorization` transaction on-chain
|
|
700
|
+
* 3. Returns the transaction hash and confirmation
|
|
701
|
+
*/
|
|
702
|
+
private async callFacilitator(payment: X402PaymentPayload): Promise<X402SettlementResult> {
|
|
703
|
+
const payer = payment.payload.authorization.from;
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
const response = await fetch(`${this.config.facilitatorUrl}/settle`, {
|
|
707
|
+
method: 'POST',
|
|
708
|
+
headers: { 'Content-Type': 'application/json' },
|
|
709
|
+
body: JSON.stringify({
|
|
710
|
+
x402Version: X402_VERSION,
|
|
711
|
+
scheme: payment.scheme,
|
|
712
|
+
network: payment.network,
|
|
713
|
+
payload: payment.payload,
|
|
714
|
+
}),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (!response.ok) {
|
|
718
|
+
const error = await response.text().catch(() => response.statusText);
|
|
719
|
+
return {
|
|
720
|
+
success: false,
|
|
721
|
+
transaction: null,
|
|
722
|
+
network: payment.network,
|
|
723
|
+
payer,
|
|
724
|
+
errorReason: `Facilitator returned ${response.status}: ${error}`,
|
|
725
|
+
mode: 'on_chain',
|
|
726
|
+
settledAt: Date.now(),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const result = (await response.json()) as {
|
|
731
|
+
success: boolean;
|
|
732
|
+
transaction?: string;
|
|
733
|
+
errorReason?: string;
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
success: result.success,
|
|
738
|
+
transaction: result.transaction ?? null,
|
|
739
|
+
network: payment.network,
|
|
740
|
+
payer,
|
|
741
|
+
errorReason: result.errorReason ?? null,
|
|
742
|
+
mode: 'on_chain',
|
|
743
|
+
settledAt: Date.now(),
|
|
744
|
+
};
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return {
|
|
747
|
+
success: false,
|
|
748
|
+
transaction: null,
|
|
749
|
+
network: payment.network,
|
|
750
|
+
payer,
|
|
751
|
+
errorReason: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
752
|
+
mode: 'on_chain',
|
|
753
|
+
settledAt: Date.now(),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ===========================================================================
|
|
759
|
+
// BATCH SETTLEMENT
|
|
760
|
+
// ===========================================================================
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Start automatic batch settlement of in-memory ledger entries.
|
|
764
|
+
*/
|
|
765
|
+
startBatchSettlement(): void {
|
|
766
|
+
if (this.batchSettlementTimer) return;
|
|
767
|
+
|
|
768
|
+
this.batchSettlementTimer = setInterval(() => {
|
|
769
|
+
this.runBatchSettlement().catch((err) => {
|
|
770
|
+
console.error('[x402] Batch settlement error:', err);
|
|
771
|
+
});
|
|
772
|
+
}, this.config.batchSettlementIntervalMs);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Stop automatic batch settlement.
|
|
777
|
+
*/
|
|
778
|
+
stopBatchSettlement(): void {
|
|
779
|
+
if (this.batchSettlementTimer) {
|
|
780
|
+
clearInterval(this.batchSettlementTimer);
|
|
781
|
+
this.batchSettlementTimer = null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Run a single batch settlement cycle.
|
|
787
|
+
* Aggregates unsettled micro-payments by payer and submits on-chain.
|
|
788
|
+
*/
|
|
789
|
+
async runBatchSettlement(): Promise<{
|
|
790
|
+
settled: number;
|
|
791
|
+
failed: number;
|
|
792
|
+
totalVolume: number;
|
|
793
|
+
}> {
|
|
794
|
+
const unsettled = this.ledger.getUnsettled();
|
|
795
|
+
if (unsettled.length === 0) {
|
|
796
|
+
return { settled: 0, failed: 0, totalVolume: 0 };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Aggregate by payer
|
|
800
|
+
const byPayer = new Map<string, LedgerEntry[]>();
|
|
801
|
+
for (const entry of unsettled) {
|
|
802
|
+
const existing = byPayer.get(entry.from) ?? [];
|
|
803
|
+
existing.push(entry);
|
|
804
|
+
byPayer.set(entry.from, existing);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let settled = 0;
|
|
808
|
+
let failed = 0;
|
|
809
|
+
let totalVolume = 0;
|
|
810
|
+
|
|
811
|
+
for (const [_payer, entries] of byPayer) {
|
|
812
|
+
const totalAmount = entries.reduce((sum, e) => sum + e.amount, 0);
|
|
813
|
+
totalVolume += totalAmount;
|
|
814
|
+
|
|
815
|
+
// In a real implementation, this would submit an aggregated
|
|
816
|
+
// on-chain transaction. For now, mark as settled.
|
|
817
|
+
const entryIds = entries.map((e) => e.id);
|
|
818
|
+
const batchTxHash = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
819
|
+
|
|
820
|
+
this.ledger.markSettled(entryIds, batchTxHash);
|
|
821
|
+
settled += entries.length;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return { settled, failed, totalVolume };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ===========================================================================
|
|
828
|
+
// X-PAYMENT HEADER HELPERS
|
|
829
|
+
// ===========================================================================
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Decode a base64-encoded X-PAYMENT header into a payment payload.
|
|
833
|
+
*/
|
|
834
|
+
static decodeXPaymentHeader(header: string): X402PaymentPayload | null {
|
|
835
|
+
try {
|
|
836
|
+
const decoded =
|
|
837
|
+
typeof atob === 'function' ? atob(header) : Buffer.from(header, 'base64').toString('utf-8');
|
|
838
|
+
return JSON.parse(decoded) as X402PaymentPayload;
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Encode a payment payload into a base64 string for the X-PAYMENT header.
|
|
846
|
+
*/
|
|
847
|
+
static encodeXPaymentHeader(payload: X402PaymentPayload): string {
|
|
848
|
+
const json = JSON.stringify(payload);
|
|
849
|
+
return typeof btoa === 'function' ? btoa(json) : Buffer.from(json, 'utf-8').toString('base64');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Create an X-PAYMENT-RESPONSE header value from a settlement result.
|
|
854
|
+
*/
|
|
855
|
+
static createPaymentResponseHeader(result: X402SettlementResult): string {
|
|
856
|
+
const response = {
|
|
857
|
+
success: result.success,
|
|
858
|
+
transaction: result.transaction,
|
|
859
|
+
network: result.network,
|
|
860
|
+
payer: result.payer,
|
|
861
|
+
errorReason: result.errorReason,
|
|
862
|
+
};
|
|
863
|
+
const json = JSON.stringify(response);
|
|
864
|
+
return typeof btoa === 'function' ? btoa(json) : Buffer.from(json, 'utf-8').toString('base64');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ===========================================================================
|
|
868
|
+
// QUERY / STATUS
|
|
869
|
+
// ===========================================================================
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Check the settlement status of a pending optimistic execution.
|
|
873
|
+
*/
|
|
874
|
+
getSettlementStatus(nonce: string): X402SettlementResult | 'pending' | 'unknown' {
|
|
875
|
+
const result = this.settlementResults.get(nonce);
|
|
876
|
+
if (result) return result;
|
|
877
|
+
if (this.pendingSettlements.has(nonce)) return 'pending';
|
|
878
|
+
return 'unknown';
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Get the in-memory ledger instance.
|
|
883
|
+
*/
|
|
884
|
+
getLedger(): MicroPaymentLedger {
|
|
885
|
+
return this.ledger;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Get facilitator statistics.
|
|
890
|
+
*/
|
|
891
|
+
getStats(): {
|
|
892
|
+
usedNonces: number;
|
|
893
|
+
pendingSettlements: number;
|
|
894
|
+
completedSettlements: number;
|
|
895
|
+
ledger: ReturnType<MicroPaymentLedger['getStats']>;
|
|
896
|
+
} {
|
|
897
|
+
return {
|
|
898
|
+
usedNonces: this.usedNonces.size,
|
|
899
|
+
pendingSettlements: this.pendingSettlements.size,
|
|
900
|
+
completedSettlements: this.settlementResults.size,
|
|
901
|
+
ledger: this.ledger.getStats(),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Clean up resources.
|
|
907
|
+
*/
|
|
908
|
+
dispose(): void {
|
|
909
|
+
this.stopBatchSettlement();
|
|
910
|
+
this.usedNonces.clear();
|
|
911
|
+
this.pendingSettlements.clear();
|
|
912
|
+
this.settlementResults.clear();
|
|
913
|
+
this.ledger.reset();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// =============================================================================
|
|
918
|
+
// @credit TRAIT HANDLER
|
|
919
|
+
// =============================================================================
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Configuration for the @credit trait.
|
|
923
|
+
* Attach to any HoloScript object to gate it behind x402 payment.
|
|
924
|
+
*/
|
|
925
|
+
export interface CreditTraitConfig {
|
|
926
|
+
/** Price in USDC (human-readable, e.g., 0.05 = 5 cents) */
|
|
927
|
+
price: number;
|
|
928
|
+
/** Settlement chain */
|
|
929
|
+
chain: SettlementChain;
|
|
930
|
+
/** Recipient address */
|
|
931
|
+
recipient: string;
|
|
932
|
+
/** Human-readable resource description */
|
|
933
|
+
description: string;
|
|
934
|
+
/** Maximum seconds for payment timeout */
|
|
935
|
+
timeout: number;
|
|
936
|
+
/** Secondary chain for multi-chain support */
|
|
937
|
+
secondary_chain?: SettlementChain;
|
|
938
|
+
/** Enable optimistic execution */
|
|
939
|
+
optimistic: boolean;
|
|
940
|
+
/** Micro-payment threshold override (USDC, human-readable) */
|
|
941
|
+
micro_threshold?: number;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Internal state for the @credit trait.
|
|
946
|
+
*/
|
|
947
|
+
interface CreditTraitState {
|
|
948
|
+
facilitator: X402Facilitator;
|
|
949
|
+
accessGranted: Map<string, { grantedAt: number; expiresAt: number; settlementId: string }>;
|
|
950
|
+
totalRevenue: number;
|
|
951
|
+
totalRequests: number;
|
|
952
|
+
totalGranted: number;
|
|
953
|
+
totalDenied: number;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* @credit Trait Handler
|
|
958
|
+
*
|
|
959
|
+
* When attached to a HoloScript object, this trait gates access behind
|
|
960
|
+
* x402 payment. The trait:
|
|
961
|
+
*
|
|
962
|
+
* 1. On access attempt: emits 'credit:payment_required' with the 402 response body
|
|
963
|
+
* 2. On payment received: verifies via facilitator and emits 'credit:access_granted'
|
|
964
|
+
* 3. On payment failure: emits 'credit:access_denied'
|
|
965
|
+
*
|
|
966
|
+
* Events emitted:
|
|
967
|
+
* credit:initialized { config }
|
|
968
|
+
* credit:payment_required { resource, paymentRequired: X402PaymentRequired }
|
|
969
|
+
* credit:access_granted { payer, amount, mode, resource }
|
|
970
|
+
* credit:access_denied { payer, reason, resource }
|
|
971
|
+
* credit:settlement_status { nonce, status }
|
|
972
|
+
* credit:batch_settled { settled, failed, totalVolume }
|
|
973
|
+
* credit:stats { totalRevenue, totalRequests, totalGranted, totalDenied }
|
|
974
|
+
*
|
|
975
|
+
* @example HoloScript usage:
|
|
976
|
+
* ```holoscript
|
|
977
|
+
* object "premium_scene" {
|
|
978
|
+
* @credit(price: 0.05, chain: "base", recipient: "0x...", description: "Premium VR scene")
|
|
979
|
+
* geometry: "sphere"
|
|
980
|
+
* color: "#gold"
|
|
981
|
+
* }
|
|
982
|
+
* ```
|
|
983
|
+
*/
|
|
984
|
+
export const creditTraitHandler: TraitHandler<CreditTraitConfig> = {
|
|
985
|
+
name: 'credit',
|
|
986
|
+
|
|
987
|
+
defaultConfig: {
|
|
988
|
+
price: 0.01,
|
|
989
|
+
chain: 'base',
|
|
990
|
+
recipient: '0x0000000000000000000000000000000000000000',
|
|
991
|
+
description: 'HoloScript premium resource',
|
|
992
|
+
timeout: 60,
|
|
993
|
+
optimistic: true,
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
onAttach(node: HSPlusNode, config: CreditTraitConfig, context: TraitContext): void {
|
|
997
|
+
const facilitator = new X402Facilitator({
|
|
998
|
+
recipientAddress: config.recipient,
|
|
999
|
+
chain: config.chain,
|
|
1000
|
+
secondaryChain: config.secondary_chain,
|
|
1001
|
+
microPaymentThreshold: config.micro_threshold
|
|
1002
|
+
? Math.round(config.micro_threshold * 1_000_000)
|
|
1003
|
+
: undefined,
|
|
1004
|
+
maxTimeoutSeconds: config.timeout,
|
|
1005
|
+
optimisticExecution: config.optimistic,
|
|
1006
|
+
resourceDescription: config.description,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
const state: CreditTraitState = {
|
|
1010
|
+
facilitator,
|
|
1011
|
+
accessGranted: new Map(),
|
|
1012
|
+
totalRevenue: 0,
|
|
1013
|
+
totalRequests: 0,
|
|
1014
|
+
totalGranted: 0,
|
|
1015
|
+
totalDenied: 0,
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
node.__creditState = state;
|
|
1019
|
+
|
|
1020
|
+
context?.emit?.('credit:initialized', {
|
|
1021
|
+
price: config.price,
|
|
1022
|
+
chain: config.chain,
|
|
1023
|
+
recipient: config.recipient,
|
|
1024
|
+
description: config.description,
|
|
1025
|
+
});
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
onDetach(node: HSPlusNode, _config: CreditTraitConfig, context: TraitContext): void {
|
|
1029
|
+
const state = node.__creditState as CreditTraitState | undefined;
|
|
1030
|
+
if (state) {
|
|
1031
|
+
state.facilitator.dispose();
|
|
1032
|
+
context.emit?.('credit:shutdown', {
|
|
1033
|
+
totalRevenue: state.totalRevenue,
|
|
1034
|
+
totalRequests: state.totalRequests,
|
|
1035
|
+
totalGranted: state.totalGranted,
|
|
1036
|
+
totalDenied: state.totalDenied,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
delete node.__creditState;
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
onUpdate(node: HSPlusNode, _config: CreditTraitConfig, context: TraitContext, _delta: number): void {
|
|
1043
|
+
const state = node.__creditState as CreditTraitState | undefined;
|
|
1044
|
+
if (!state) return;
|
|
1045
|
+
|
|
1046
|
+
// Expire stale access grants
|
|
1047
|
+
const now = Date.now();
|
|
1048
|
+
for (const [payer, grant] of state.accessGranted) {
|
|
1049
|
+
if (grant.expiresAt > 0 && now > grant.expiresAt) {
|
|
1050
|
+
state.accessGranted.delete(payer);
|
|
1051
|
+
context.emit?.('credit:access_expired', { payer, resource: _config.description });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
|
|
1056
|
+
onEvent(node: HSPlusNode, config: CreditTraitConfig, context: TraitContext, event: TraitEvent): void {
|
|
1057
|
+
const state = node.__creditState as CreditTraitState | undefined;
|
|
1058
|
+
if (!state) return;
|
|
1059
|
+
|
|
1060
|
+
const eventType = typeof event === 'string' ? event : event.type;
|
|
1061
|
+
const payload = event?.payload ?? event;
|
|
1062
|
+
|
|
1063
|
+
switch (eventType) {
|
|
1064
|
+
// ─── Request access (generates 402 response) ─────────────────────
|
|
1065
|
+
case 'credit:request_access': {
|
|
1066
|
+
state.totalRequests++;
|
|
1067
|
+
const resource = payload.resource ?? config.description;
|
|
1068
|
+
const payer = payload.payer ?? payload.from;
|
|
1069
|
+
|
|
1070
|
+
// Check if already granted
|
|
1071
|
+
const existing = state.accessGranted.get(payer);
|
|
1072
|
+
if (existing && (existing.expiresAt === 0 || Date.now() < existing.expiresAt)) {
|
|
1073
|
+
context.emit?.('credit:access_granted', {
|
|
1074
|
+
payer,
|
|
1075
|
+
amount: 0,
|
|
1076
|
+
mode: 'cached',
|
|
1077
|
+
resource,
|
|
1078
|
+
});
|
|
1079
|
+
state.totalGranted++;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Generate 402 PaymentRequired
|
|
1084
|
+
const paymentRequired = state.facilitator.createPaymentRequired(
|
|
1085
|
+
resource,
|
|
1086
|
+
config.price,
|
|
1087
|
+
config.description
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
context.emit?.('credit:payment_required', {
|
|
1091
|
+
resource,
|
|
1092
|
+
paymentRequired,
|
|
1093
|
+
statusCode: 402,
|
|
1094
|
+
headers: {
|
|
1095
|
+
'Content-Type': 'application/json',
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ─── Submit payment (process X-PAYMENT header) ───────────────────
|
|
1102
|
+
case 'credit:submit_payment': {
|
|
1103
|
+
const resource = payload.resource ?? config.description;
|
|
1104
|
+
const xPaymentHeader = payload.xPayment ?? payload.payment;
|
|
1105
|
+
|
|
1106
|
+
if (!xPaymentHeader) {
|
|
1107
|
+
context.emit?.('credit:access_denied', {
|
|
1108
|
+
payer: 'unknown',
|
|
1109
|
+
reason: 'Missing X-PAYMENT header',
|
|
1110
|
+
resource,
|
|
1111
|
+
});
|
|
1112
|
+
state.totalDenied++;
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Decode the X-PAYMENT header
|
|
1117
|
+
const paymentPayload =
|
|
1118
|
+
typeof xPaymentHeader === 'string'
|
|
1119
|
+
? X402Facilitator.decodeXPaymentHeader(xPaymentHeader)
|
|
1120
|
+
: (xPaymentHeader as X402PaymentPayload);
|
|
1121
|
+
|
|
1122
|
+
if (!paymentPayload) {
|
|
1123
|
+
context.emit?.('credit:access_denied', {
|
|
1124
|
+
payer: 'unknown',
|
|
1125
|
+
reason: 'Invalid X-PAYMENT header encoding',
|
|
1126
|
+
resource,
|
|
1127
|
+
});
|
|
1128
|
+
state.totalDenied++;
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const requiredAmount = Math.round(config.price * 1_000_000).toString();
|
|
1133
|
+
const payer = paymentPayload.payload.authorization.from;
|
|
1134
|
+
|
|
1135
|
+
// Process payment (async, but we handle it via event)
|
|
1136
|
+
state.facilitator
|
|
1137
|
+
.processPayment(paymentPayload, resource, requiredAmount)
|
|
1138
|
+
.then((result) => {
|
|
1139
|
+
if (result.success) {
|
|
1140
|
+
// Grant access
|
|
1141
|
+
state.accessGranted.set(payer, {
|
|
1142
|
+
grantedAt: Date.now(),
|
|
1143
|
+
expiresAt: config.timeout > 0 ? Date.now() + config.timeout * 1000 : 0,
|
|
1144
|
+
settlementId: result.transaction ?? '',
|
|
1145
|
+
});
|
|
1146
|
+
state.totalRevenue += config.price;
|
|
1147
|
+
state.totalGranted++;
|
|
1148
|
+
|
|
1149
|
+
context.emit?.('credit:access_granted', {
|
|
1150
|
+
payer,
|
|
1151
|
+
amount: config.price,
|
|
1152
|
+
mode: result.mode,
|
|
1153
|
+
resource,
|
|
1154
|
+
transaction: result.transaction,
|
|
1155
|
+
network: result.network,
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// Emit X-PAYMENT-RESPONSE header for the HTTP response
|
|
1159
|
+
context.emit?.('credit:payment_response', {
|
|
1160
|
+
resource,
|
|
1161
|
+
headers: {
|
|
1162
|
+
'X-PAYMENT-RESPONSE': X402Facilitator.createPaymentResponseHeader(result),
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
} else {
|
|
1166
|
+
state.totalDenied++;
|
|
1167
|
+
context.emit?.('credit:access_denied', {
|
|
1168
|
+
payer,
|
|
1169
|
+
reason: result.errorReason,
|
|
1170
|
+
resource,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
.catch((err) => {
|
|
1175
|
+
state.totalDenied++;
|
|
1176
|
+
context.emit?.('credit:access_denied', {
|
|
1177
|
+
payer,
|
|
1178
|
+
reason: `Settlement error: ${err instanceof Error ? err.message : String(err)}`,
|
|
1179
|
+
resource,
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ─── Check settlement status ─────────────────────────────────────
|
|
1186
|
+
case 'credit:check_settlement': {
|
|
1187
|
+
const nonce = payload.nonce;
|
|
1188
|
+
if (!nonce) return;
|
|
1189
|
+
|
|
1190
|
+
const status = state.facilitator.getSettlementStatus(nonce);
|
|
1191
|
+
context.emit?.('credit:settlement_status', { nonce, status });
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ─── Run batch settlement ────────────────────────────────────────
|
|
1196
|
+
case 'credit:batch_settle': {
|
|
1197
|
+
state.facilitator
|
|
1198
|
+
.runBatchSettlement()
|
|
1199
|
+
.then((result) => {
|
|
1200
|
+
context.emit?.('credit:batch_settled', result);
|
|
1201
|
+
})
|
|
1202
|
+
.catch((err) => {
|
|
1203
|
+
context.emit?.('credit:batch_error', {
|
|
1204
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ─── Query stats ─────────────────────────────────────────────────
|
|
1211
|
+
case 'credit:get_stats': {
|
|
1212
|
+
const facilStats = state.facilitator.getStats();
|
|
1213
|
+
context.emit?.('credit:stats', {
|
|
1214
|
+
totalRevenue: state.totalRevenue,
|
|
1215
|
+
totalRequests: state.totalRequests,
|
|
1216
|
+
totalGranted: state.totalGranted,
|
|
1217
|
+
totalDenied: state.totalDenied,
|
|
1218
|
+
facilitator: facilStats,
|
|
1219
|
+
});
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ─── Revoke access ───────────────────────────────────────────────
|
|
1224
|
+
case 'credit:revoke_access': {
|
|
1225
|
+
const payer = payload.payer ?? payload.from;
|
|
1226
|
+
if (payer) {
|
|
1227
|
+
state.accessGranted.delete(payer);
|
|
1228
|
+
context.emit?.('credit:access_revoked', { payer });
|
|
1229
|
+
}
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
export default creditTraitHandler;
|
|
1237
|
+
|
|
1238
|
+
// =============================================================================
|
|
1239
|
+
// CHAIN ID CONSTANTS
|
|
1240
|
+
// =============================================================================
|
|
1241
|
+
|
|
1242
|
+
/** EVM chain IDs for supported settlement networks */
|
|
1243
|
+
export const CHAIN_IDS: Record<string, number> = {
|
|
1244
|
+
base: 8453,
|
|
1245
|
+
'base-sepolia': 84532,
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
/** Reverse lookup: chain ID -> settlement chain name */
|
|
1249
|
+
export const CHAIN_ID_TO_NETWORK: Record<number, SettlementChain> = {
|
|
1250
|
+
8453: 'base',
|
|
1251
|
+
84532: 'base-sepolia',
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
// =============================================================================
|
|
1255
|
+
// SETTLEMENT EVENT TYPES
|
|
1256
|
+
// =============================================================================
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Settlement audit event types emitted by PaymentGateway.
|
|
1260
|
+
*/
|
|
1261
|
+
export type SettlementEventType =
|
|
1262
|
+
| 'payment:authorization_created'
|
|
1263
|
+
| 'payment:verification_started'
|
|
1264
|
+
| 'payment:verification_passed'
|
|
1265
|
+
| 'payment:verification_failed'
|
|
1266
|
+
| 'payment:settlement_started'
|
|
1267
|
+
| 'payment:settlement_completed'
|
|
1268
|
+
| 'payment:settlement_failed'
|
|
1269
|
+
| 'payment:refund_initiated'
|
|
1270
|
+
| 'payment:refund_completed'
|
|
1271
|
+
| 'payment:refund_failed'
|
|
1272
|
+
| 'payment:batch_settlement_started'
|
|
1273
|
+
| 'payment:batch_settlement_completed';
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Settlement audit event payload.
|
|
1277
|
+
*/
|
|
1278
|
+
export interface SettlementEvent {
|
|
1279
|
+
/** Event type */
|
|
1280
|
+
type: SettlementEventType;
|
|
1281
|
+
/** ISO 8601 timestamp */
|
|
1282
|
+
timestamp: string;
|
|
1283
|
+
/** Unique event ID */
|
|
1284
|
+
eventId: string;
|
|
1285
|
+
/** Associated payment nonce (if applicable) */
|
|
1286
|
+
nonce: string | null;
|
|
1287
|
+
/** Payer address */
|
|
1288
|
+
payer: string | null;
|
|
1289
|
+
/** Recipient address */
|
|
1290
|
+
recipient: string | null;
|
|
1291
|
+
/** Amount in USDC base units */
|
|
1292
|
+
amount: string | null;
|
|
1293
|
+
/** Settlement chain */
|
|
1294
|
+
network: SettlementChain | 'in_memory' | null;
|
|
1295
|
+
/** Transaction hash (if on-chain) */
|
|
1296
|
+
transaction: string | null;
|
|
1297
|
+
/** Additional metadata */
|
|
1298
|
+
metadata: Record<string, unknown>;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Event listener type for the PaymentGateway audit trail */
|
|
1302
|
+
export type SettlementEventListener = (event: SettlementEvent) => void;
|
|
1303
|
+
|
|
1304
|
+
// =============================================================================
|
|
1305
|
+
// REFUND TYPES
|
|
1306
|
+
// =============================================================================
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Refund request for reversing a completed payment.
|
|
1310
|
+
*/
|
|
1311
|
+
export interface RefundRequest {
|
|
1312
|
+
/** Original payment nonce to refund */
|
|
1313
|
+
originalNonce: string;
|
|
1314
|
+
/** Reason for the refund */
|
|
1315
|
+
reason: string;
|
|
1316
|
+
/** Partial refund amount in USDC base units (null = full refund) */
|
|
1317
|
+
partialAmount: string | null;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Result of a refund operation.
|
|
1322
|
+
*/
|
|
1323
|
+
export interface RefundResult {
|
|
1324
|
+
/** Whether the refund was processed */
|
|
1325
|
+
success: boolean;
|
|
1326
|
+
/** Refund ID for tracking */
|
|
1327
|
+
refundId: string;
|
|
1328
|
+
/** Amount refunded in USDC base units */
|
|
1329
|
+
amountRefunded: string;
|
|
1330
|
+
/** Original payment nonce */
|
|
1331
|
+
originalNonce: string;
|
|
1332
|
+
/** Transaction hash (for on-chain refunds) */
|
|
1333
|
+
transaction: string | null;
|
|
1334
|
+
/** Mode of the original settlement */
|
|
1335
|
+
originalMode: SettlementMode;
|
|
1336
|
+
/** Reason for the refund */
|
|
1337
|
+
reason: string;
|
|
1338
|
+
/** Error reason if failed */
|
|
1339
|
+
errorReason: string | null;
|
|
1340
|
+
/** Timestamp of refund */
|
|
1341
|
+
refundedAt: number;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// =============================================================================
|
|
1345
|
+
// PAYMENT GATEWAY
|
|
1346
|
+
// =============================================================================
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* PaymentGateway -- High-Level x402 Payment API
|
|
1350
|
+
*
|
|
1351
|
+
* Provides a unified, agent-friendly interface for the x402 payment protocol.
|
|
1352
|
+
* Composes over X402Facilitator to add:
|
|
1353
|
+
*
|
|
1354
|
+
* - `createPaymentAuthorization()` -- Generate 402 Payment Required responses
|
|
1355
|
+
* - `verifyPayment()` -- Validate X-PAYMENT headers
|
|
1356
|
+
* - `settlePayment()` -- Process and settle payments (micro or on-chain)
|
|
1357
|
+
* - `refundPayment()` -- Reverse completed transactions
|
|
1358
|
+
* - Settlement event emitter for audit trail
|
|
1359
|
+
* - Chain ID constants for Base L2 (8453)
|
|
1360
|
+
*
|
|
1361
|
+
* Settlement flow:
|
|
1362
|
+
* 1. Agent calls resource -> gateway returns 402 with payment requirements
|
|
1363
|
+
* 2. Agent signs EIP-712 authorization -> sends X-PAYMENT header
|
|
1364
|
+
* 3. Gateway verifies signature validity and temporal window
|
|
1365
|
+
* 4. Gateway settles payment (in-memory for micro, on-chain for macro)
|
|
1366
|
+
* 5. Gateway emits audit events at each step
|
|
1367
|
+
*
|
|
1368
|
+
* @example
|
|
1369
|
+
* ```typescript
|
|
1370
|
+
* const gateway = new PaymentGateway({
|
|
1371
|
+
* recipientAddress: '0x...',
|
|
1372
|
+
* chain: 'base',
|
|
1373
|
+
* });
|
|
1374
|
+
*
|
|
1375
|
+
* // Listen for audit events
|
|
1376
|
+
* gateway.on('payment:settlement_completed', (event) => {
|
|
1377
|
+
* console.log(`Payment settled: ${event.transaction}`);
|
|
1378
|
+
* });
|
|
1379
|
+
*
|
|
1380
|
+
* // Step 1: Generate 402 response
|
|
1381
|
+
* const auth = gateway.createPaymentAuthorization('/api/premium-scene', 0.05);
|
|
1382
|
+
*
|
|
1383
|
+
* // Step 2: Verify incoming payment
|
|
1384
|
+
* const verification = gateway.verifyPayment(xPaymentHeader, '50000');
|
|
1385
|
+
*
|
|
1386
|
+
* // Step 3: Settle
|
|
1387
|
+
* const settlement = await gateway.settlePayment(paymentPayload, '/api/premium-scene', '50000');
|
|
1388
|
+
*
|
|
1389
|
+
* // Step 4: Refund if needed
|
|
1390
|
+
* const refund = await gateway.refundPayment({
|
|
1391
|
+
* originalNonce: 'nonce_123',
|
|
1392
|
+
* reason: 'Content unavailable',
|
|
1393
|
+
* partialAmount: null,
|
|
1394
|
+
* });
|
|
1395
|
+
* ```
|
|
1396
|
+
*/
|
|
1397
|
+
export class PaymentGateway {
|
|
1398
|
+
private facilitator: X402Facilitator;
|
|
1399
|
+
private listeners: Map<SettlementEventType | '*', Set<SettlementEventListener>> = new Map();
|
|
1400
|
+
private refundLedger: Map<string, RefundResult> = new Map();
|
|
1401
|
+
private eventCounter = 0;
|
|
1402
|
+
private readonly config: X402FacilitatorConfig;
|
|
1403
|
+
|
|
1404
|
+
constructor(config: X402FacilitatorConfig) {
|
|
1405
|
+
this.config = config;
|
|
1406
|
+
this.facilitator = new X402Facilitator(config);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// ===========================================================================
|
|
1410
|
+
// EVENT EMITTER (Audit Trail)
|
|
1411
|
+
// ===========================================================================
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Subscribe to settlement audit events.
|
|
1415
|
+
* Use '*' to receive all events.
|
|
1416
|
+
*
|
|
1417
|
+
* @param eventType - Event type to listen for, or '*' for all
|
|
1418
|
+
* @param listener - Callback function
|
|
1419
|
+
* @returns Unsubscribe function
|
|
1420
|
+
*/
|
|
1421
|
+
on(eventType: SettlementEventType | '*', listener: SettlementEventListener): () => void {
|
|
1422
|
+
if (!this.listeners.has(eventType)) {
|
|
1423
|
+
this.listeners.set(eventType, new Set());
|
|
1424
|
+
}
|
|
1425
|
+
this.listeners.get(eventType)!.add(listener);
|
|
1426
|
+
|
|
1427
|
+
return () => {
|
|
1428
|
+
this.listeners.get(eventType)?.delete(listener);
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Remove a specific listener.
|
|
1434
|
+
*/
|
|
1435
|
+
off(eventType: SettlementEventType | '*', listener: SettlementEventListener): void {
|
|
1436
|
+
this.listeners.get(eventType)?.delete(listener);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Emit a settlement audit event.
|
|
1441
|
+
*/
|
|
1442
|
+
private emit(
|
|
1443
|
+
type: SettlementEventType,
|
|
1444
|
+
data: Partial<Omit<SettlementEvent, 'type' | 'timestamp' | 'eventId'>>
|
|
1445
|
+
): SettlementEvent {
|
|
1446
|
+
const event: SettlementEvent = {
|
|
1447
|
+
type,
|
|
1448
|
+
timestamp: new Date().toISOString(),
|
|
1449
|
+
eventId: `evt_${Date.now()}_${this.eventCounter++}`,
|
|
1450
|
+
nonce: data.nonce ?? null,
|
|
1451
|
+
payer: data.payer ?? null,
|
|
1452
|
+
recipient: data.recipient ?? null,
|
|
1453
|
+
amount: data.amount ?? null,
|
|
1454
|
+
network: data.network ?? null,
|
|
1455
|
+
transaction: data.transaction ?? null,
|
|
1456
|
+
metadata: data.metadata ?? {},
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
// Notify specific listeners
|
|
1460
|
+
const typeListeners = this.listeners.get(type);
|
|
1461
|
+
if (typeListeners) {
|
|
1462
|
+
for (const listener of typeListeners) {
|
|
1463
|
+
try {
|
|
1464
|
+
listener(event);
|
|
1465
|
+
} catch {
|
|
1466
|
+
// Swallow listener errors to prevent breaking the payment flow
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Notify wildcard listeners
|
|
1472
|
+
const wildcardListeners = this.listeners.get('*');
|
|
1473
|
+
if (wildcardListeners) {
|
|
1474
|
+
for (const listener of wildcardListeners) {
|
|
1475
|
+
try {
|
|
1476
|
+
listener(event);
|
|
1477
|
+
} catch {
|
|
1478
|
+
// Swallow listener errors
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return event;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// ===========================================================================
|
|
1487
|
+
// PAYMENT AUTHORIZATION
|
|
1488
|
+
// ===========================================================================
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Create a payment authorization (HTTP 402 response body).
|
|
1492
|
+
*
|
|
1493
|
+
* This is step 1 of the x402 flow: the server tells the agent what payment
|
|
1494
|
+
* is required to access the resource.
|
|
1495
|
+
*
|
|
1496
|
+
* @param resource - Resource path being gated (e.g., "/api/premium-scene")
|
|
1497
|
+
* @param amountUSDC - Price in USDC (human-readable, e.g., 0.05 for 5 cents)
|
|
1498
|
+
* @param description - Human-readable description
|
|
1499
|
+
* @returns x402 PaymentRequired response body
|
|
1500
|
+
*/
|
|
1501
|
+
createPaymentAuthorization(
|
|
1502
|
+
resource: string,
|
|
1503
|
+
amountUSDC: number,
|
|
1504
|
+
description?: string
|
|
1505
|
+
): X402PaymentRequired & { chainId: number } {
|
|
1506
|
+
const paymentRequired = this.facilitator.createPaymentRequired(
|
|
1507
|
+
resource,
|
|
1508
|
+
amountUSDC,
|
|
1509
|
+
description
|
|
1510
|
+
);
|
|
1511
|
+
|
|
1512
|
+
this.emit('payment:authorization_created', {
|
|
1513
|
+
recipient: this.config.recipientAddress,
|
|
1514
|
+
amount: Math.round(amountUSDC * 1_000_000).toString(),
|
|
1515
|
+
network: this.config.chain,
|
|
1516
|
+
metadata: { resource, description: description ?? '' },
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
return {
|
|
1520
|
+
...paymentRequired,
|
|
1521
|
+
chainId: CHAIN_IDS[this.config.chain] ?? 0,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ===========================================================================
|
|
1526
|
+
// PAYMENT VERIFICATION
|
|
1527
|
+
// ===========================================================================
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Verify an X-PAYMENT header.
|
|
1531
|
+
*
|
|
1532
|
+
* Accepts either a raw base64 string (from HTTP header) or a decoded payload.
|
|
1533
|
+
* Validates protocol version, nonce, temporal window, amount, and recipient.
|
|
1534
|
+
*
|
|
1535
|
+
* @param payment - Base64-encoded X-PAYMENT header string or decoded payload
|
|
1536
|
+
* @param requiredAmount - Required amount in USDC base units (string for precision)
|
|
1537
|
+
* @returns Verification result
|
|
1538
|
+
*/
|
|
1539
|
+
verifyPayment(
|
|
1540
|
+
payment: string | X402PaymentPayload,
|
|
1541
|
+
requiredAmount: string
|
|
1542
|
+
): X402VerificationResult & { decodedPayload: X402PaymentPayload | null } {
|
|
1543
|
+
// Decode if string
|
|
1544
|
+
const payload: X402PaymentPayload | null =
|
|
1545
|
+
typeof payment === 'string' ? X402Facilitator.decodeXPaymentHeader(payment) : payment;
|
|
1546
|
+
|
|
1547
|
+
if (!payload) {
|
|
1548
|
+
this.emit('payment:verification_failed', {
|
|
1549
|
+
metadata: { reason: 'Failed to decode X-PAYMENT header' },
|
|
1550
|
+
});
|
|
1551
|
+
return {
|
|
1552
|
+
isValid: false,
|
|
1553
|
+
invalidReason: 'Failed to decode X-PAYMENT header',
|
|
1554
|
+
decodedPayload: null,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const payer = payload.payload.authorization.from;
|
|
1559
|
+
const nonce = payload.payload.authorization.nonce;
|
|
1560
|
+
|
|
1561
|
+
this.emit('payment:verification_started', {
|
|
1562
|
+
payer,
|
|
1563
|
+
nonce,
|
|
1564
|
+
amount: payload.payload.authorization.value,
|
|
1565
|
+
network: payload.network,
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
const result = this.facilitator.verifyPayment(payload, requiredAmount);
|
|
1569
|
+
|
|
1570
|
+
if (result.isValid) {
|
|
1571
|
+
this.emit('payment:verification_passed', {
|
|
1572
|
+
payer,
|
|
1573
|
+
nonce,
|
|
1574
|
+
amount: payload.payload.authorization.value,
|
|
1575
|
+
network: payload.network,
|
|
1576
|
+
});
|
|
1577
|
+
} else {
|
|
1578
|
+
this.emit('payment:verification_failed', {
|
|
1579
|
+
payer,
|
|
1580
|
+
nonce,
|
|
1581
|
+
metadata: { reason: result.invalidReason },
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
return { ...result, decodedPayload: payload };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// ===========================================================================
|
|
1589
|
+
// PAYMENT SETTLEMENT
|
|
1590
|
+
// ===========================================================================
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Settle a verified payment.
|
|
1594
|
+
*
|
|
1595
|
+
* Routes to in-memory micro-payment ledger or on-chain settlement
|
|
1596
|
+
* depending on the amount. Emits audit events at each stage.
|
|
1597
|
+
*
|
|
1598
|
+
* @param payment - Decoded X-PAYMENT payload (or base64 string)
|
|
1599
|
+
* @param resource - Resource being accessed
|
|
1600
|
+
* @param requiredAmount - Required amount in USDC base units
|
|
1601
|
+
* @returns Settlement result
|
|
1602
|
+
*/
|
|
1603
|
+
async settlePayment(
|
|
1604
|
+
payment: string | X402PaymentPayload,
|
|
1605
|
+
resource: string,
|
|
1606
|
+
requiredAmount: string
|
|
1607
|
+
): Promise<X402SettlementResult> {
|
|
1608
|
+
// Decode if string
|
|
1609
|
+
const payload: X402PaymentPayload | null =
|
|
1610
|
+
typeof payment === 'string' ? X402Facilitator.decodeXPaymentHeader(payment) : payment;
|
|
1611
|
+
|
|
1612
|
+
if (!payload) {
|
|
1613
|
+
return {
|
|
1614
|
+
success: false,
|
|
1615
|
+
transaction: null,
|
|
1616
|
+
network: this.config.chain,
|
|
1617
|
+
payer: 'unknown',
|
|
1618
|
+
errorReason: 'Failed to decode payment payload',
|
|
1619
|
+
mode: 'on_chain',
|
|
1620
|
+
settledAt: Date.now(),
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const payer = payload.payload.authorization.from;
|
|
1625
|
+
const nonce = payload.payload.authorization.nonce;
|
|
1626
|
+
const amount = payload.payload.authorization.value;
|
|
1627
|
+
|
|
1628
|
+
this.emit('payment:settlement_started', {
|
|
1629
|
+
payer,
|
|
1630
|
+
nonce,
|
|
1631
|
+
amount,
|
|
1632
|
+
network: payload.network,
|
|
1633
|
+
recipient: this.config.recipientAddress,
|
|
1634
|
+
metadata: { resource },
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
const result = await this.facilitator.processPayment(payload, resource, requiredAmount);
|
|
1638
|
+
|
|
1639
|
+
if (result.success) {
|
|
1640
|
+
this.emit('payment:settlement_completed', {
|
|
1641
|
+
payer,
|
|
1642
|
+
nonce,
|
|
1643
|
+
amount,
|
|
1644
|
+
network: result.network,
|
|
1645
|
+
transaction: result.transaction,
|
|
1646
|
+
recipient: this.config.recipientAddress,
|
|
1647
|
+
metadata: { resource, mode: result.mode },
|
|
1648
|
+
});
|
|
1649
|
+
} else {
|
|
1650
|
+
this.emit('payment:settlement_failed', {
|
|
1651
|
+
payer,
|
|
1652
|
+
nonce,
|
|
1653
|
+
amount,
|
|
1654
|
+
network: result.network,
|
|
1655
|
+
metadata: { resource, errorReason: result.errorReason },
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
return result;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// ===========================================================================
|
|
1663
|
+
// REFUND
|
|
1664
|
+
// ===========================================================================
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Refund a completed payment.
|
|
1668
|
+
*
|
|
1669
|
+
* For in-memory micro-payments: records a reverse entry in the ledger.
|
|
1670
|
+
* For on-chain payments: calls the facilitator service to initiate refund.
|
|
1671
|
+
*
|
|
1672
|
+
* @param request - Refund request details
|
|
1673
|
+
* @returns Refund result
|
|
1674
|
+
*/
|
|
1675
|
+
async refundPayment(request: RefundRequest): Promise<RefundResult> {
|
|
1676
|
+
const { originalNonce, reason, partialAmount } = request;
|
|
1677
|
+
|
|
1678
|
+
this.emit('payment:refund_initiated', {
|
|
1679
|
+
nonce: originalNonce,
|
|
1680
|
+
metadata: { reason, partialAmount },
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
// Look up the original settlement
|
|
1684
|
+
const originalStatus = this.facilitator.getSettlementStatus(originalNonce);
|
|
1685
|
+
|
|
1686
|
+
// Check if the original was a micro-payment by looking in the ledger
|
|
1687
|
+
const ledger = this.facilitator.getLedger();
|
|
1688
|
+
const allEntries = ledger.getEntriesForPayer(''); // We need to search all entries
|
|
1689
|
+
|
|
1690
|
+
// Try to find the original ledger entry by checking if transaction matches
|
|
1691
|
+
let originalEntry: LedgerEntry | undefined;
|
|
1692
|
+
const ledgerStats = ledger.getStats();
|
|
1693
|
+
|
|
1694
|
+
// For micro-payments, the transaction ID starts with "micro_"
|
|
1695
|
+
// For on-chain, we check the settlement results
|
|
1696
|
+
if (originalStatus !== 'unknown' && originalStatus !== 'pending') {
|
|
1697
|
+
const settlement = originalStatus as X402SettlementResult;
|
|
1698
|
+
|
|
1699
|
+
if (settlement.mode === 'in_memory' && settlement.transaction) {
|
|
1700
|
+
// It was a micro-payment -- record a reverse entry
|
|
1701
|
+
const refundAmount =
|
|
1702
|
+
(partialAmount ?? settlement.transaction)
|
|
1703
|
+
? '0' // We'll try to find the amount from the ledger
|
|
1704
|
+
: '0';
|
|
1705
|
+
|
|
1706
|
+
const refundId = `refund_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1707
|
+
|
|
1708
|
+
// Record reverse ledger entry (swap from/to)
|
|
1709
|
+
const reverseEntry = ledger.record(
|
|
1710
|
+
this.config.recipientAddress,
|
|
1711
|
+
settlement.payer,
|
|
1712
|
+
partialAmount ? parseInt(partialAmount, 10) : 0,
|
|
1713
|
+
`refund:${originalNonce}`
|
|
1714
|
+
);
|
|
1715
|
+
|
|
1716
|
+
const result: RefundResult = {
|
|
1717
|
+
success: true,
|
|
1718
|
+
refundId,
|
|
1719
|
+
amountRefunded: partialAmount ?? '0',
|
|
1720
|
+
originalNonce,
|
|
1721
|
+
transaction: reverseEntry.id,
|
|
1722
|
+
originalMode: 'in_memory',
|
|
1723
|
+
reason,
|
|
1724
|
+
errorReason: null,
|
|
1725
|
+
refundedAt: Date.now(),
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
this.refundLedger.set(refundId, result);
|
|
1729
|
+
|
|
1730
|
+
this.emit('payment:refund_completed', {
|
|
1731
|
+
nonce: originalNonce,
|
|
1732
|
+
payer: settlement.payer,
|
|
1733
|
+
amount: result.amountRefunded,
|
|
1734
|
+
transaction: reverseEntry.id,
|
|
1735
|
+
network: 'in_memory',
|
|
1736
|
+
metadata: { reason, refundId },
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
return result;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (settlement.mode === 'on_chain') {
|
|
1743
|
+
// On-chain refund: call the facilitator service
|
|
1744
|
+
try {
|
|
1745
|
+
const response = await fetch(
|
|
1746
|
+
`${this.config.facilitatorUrl ?? 'https://x402.org/facilitator'}/refund`,
|
|
1747
|
+
{
|
|
1748
|
+
method: 'POST',
|
|
1749
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1750
|
+
body: JSON.stringify({
|
|
1751
|
+
originalTransaction: settlement.transaction,
|
|
1752
|
+
originalNonce,
|
|
1753
|
+
refundAmount: partialAmount,
|
|
1754
|
+
reason,
|
|
1755
|
+
network: settlement.network,
|
|
1756
|
+
}),
|
|
1757
|
+
}
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
const refundId = `refund_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1761
|
+
|
|
1762
|
+
if (response.ok) {
|
|
1763
|
+
const body = (await response.json()) as {
|
|
1764
|
+
success: boolean;
|
|
1765
|
+
transaction?: string;
|
|
1766
|
+
amountRefunded?: string;
|
|
1767
|
+
errorReason?: string;
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
const result: RefundResult = {
|
|
1771
|
+
success: body.success,
|
|
1772
|
+
refundId,
|
|
1773
|
+
amountRefunded: body.amountRefunded ?? partialAmount ?? '0',
|
|
1774
|
+
originalNonce,
|
|
1775
|
+
transaction: body.transaction ?? null,
|
|
1776
|
+
originalMode: 'on_chain',
|
|
1777
|
+
reason,
|
|
1778
|
+
errorReason: body.errorReason ?? null,
|
|
1779
|
+
refundedAt: Date.now(),
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
this.refundLedger.set(refundId, result);
|
|
1783
|
+
|
|
1784
|
+
if (body.success) {
|
|
1785
|
+
this.emit('payment:refund_completed', {
|
|
1786
|
+
nonce: originalNonce,
|
|
1787
|
+
payer: settlement.payer,
|
|
1788
|
+
amount: result.amountRefunded,
|
|
1789
|
+
transaction: body.transaction ?? null,
|
|
1790
|
+
network: settlement.network as SettlementChain,
|
|
1791
|
+
metadata: { reason, refundId },
|
|
1792
|
+
});
|
|
1793
|
+
} else {
|
|
1794
|
+
this.emit('payment:refund_failed', {
|
|
1795
|
+
nonce: originalNonce,
|
|
1796
|
+
metadata: { reason, errorReason: body.errorReason, refundId },
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return result;
|
|
1801
|
+
} else {
|
|
1802
|
+
const errorText = await response.text().catch(() => response.statusText);
|
|
1803
|
+
const result: RefundResult = {
|
|
1804
|
+
success: false,
|
|
1805
|
+
refundId,
|
|
1806
|
+
amountRefunded: '0',
|
|
1807
|
+
originalNonce,
|
|
1808
|
+
transaction: null,
|
|
1809
|
+
originalMode: 'on_chain',
|
|
1810
|
+
reason,
|
|
1811
|
+
errorReason: `Facilitator returned ${response.status}: ${errorText}`,
|
|
1812
|
+
refundedAt: Date.now(),
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
this.refundLedger.set(refundId, result);
|
|
1816
|
+
|
|
1817
|
+
this.emit('payment:refund_failed', {
|
|
1818
|
+
nonce: originalNonce,
|
|
1819
|
+
metadata: { reason, errorReason: result.errorReason, refundId },
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
return result;
|
|
1823
|
+
}
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
const refundId = `refund_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1826
|
+
const result: RefundResult = {
|
|
1827
|
+
success: false,
|
|
1828
|
+
refundId,
|
|
1829
|
+
amountRefunded: '0',
|
|
1830
|
+
originalNonce,
|
|
1831
|
+
transaction: null,
|
|
1832
|
+
originalMode: 'on_chain',
|
|
1833
|
+
reason,
|
|
1834
|
+
errorReason: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
1835
|
+
refundedAt: Date.now(),
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
this.refundLedger.set(refundId, result);
|
|
1839
|
+
|
|
1840
|
+
this.emit('payment:refund_failed', {
|
|
1841
|
+
nonce: originalNonce,
|
|
1842
|
+
metadata: { reason, errorReason: result.errorReason, refundId },
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
return result;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Original payment not found or still pending
|
|
1851
|
+
const refundId = `refund_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1852
|
+
const result: RefundResult = {
|
|
1853
|
+
success: false,
|
|
1854
|
+
refundId,
|
|
1855
|
+
amountRefunded: '0',
|
|
1856
|
+
originalNonce,
|
|
1857
|
+
transaction: null,
|
|
1858
|
+
originalMode: 'in_memory',
|
|
1859
|
+
reason,
|
|
1860
|
+
errorReason:
|
|
1861
|
+
originalStatus === 'pending'
|
|
1862
|
+
? 'Cannot refund: original payment still pending settlement'
|
|
1863
|
+
: 'Cannot refund: original payment not found',
|
|
1864
|
+
refundedAt: Date.now(),
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
this.refundLedger.set(refundId, result);
|
|
1868
|
+
|
|
1869
|
+
this.emit('payment:refund_failed', {
|
|
1870
|
+
nonce: originalNonce,
|
|
1871
|
+
metadata: { reason, errorReason: result.errorReason, refundId },
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
return result;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// ===========================================================================
|
|
1878
|
+
// BATCH SETTLEMENT
|
|
1879
|
+
// ===========================================================================
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* Run a batch settlement of accumulated micro-payments.
|
|
1883
|
+
*/
|
|
1884
|
+
async runBatchSettlement(): Promise<{ settled: number; failed: number; totalVolume: number }> {
|
|
1885
|
+
this.emit('payment:batch_settlement_started', {
|
|
1886
|
+
metadata: {
|
|
1887
|
+
unsettledEntries: this.facilitator.getLedger().getStats().unsettledEntries,
|
|
1888
|
+
unsettledVolume: this.facilitator.getLedger().getStats().unsettledVolume,
|
|
1889
|
+
},
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
const result = await this.facilitator.runBatchSettlement();
|
|
1893
|
+
|
|
1894
|
+
this.emit('payment:batch_settlement_completed', {
|
|
1895
|
+
metadata: {
|
|
1896
|
+
settled: result.settled,
|
|
1897
|
+
failed: result.failed,
|
|
1898
|
+
totalVolume: result.totalVolume,
|
|
1899
|
+
},
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
return result;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ===========================================================================
|
|
1906
|
+
// QUERY / STATUS
|
|
1907
|
+
// ===========================================================================
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* Get the underlying facilitator instance.
|
|
1911
|
+
*/
|
|
1912
|
+
getFacilitator(): X402Facilitator {
|
|
1913
|
+
return this.facilitator;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Get chain ID for the configured settlement chain.
|
|
1918
|
+
*/
|
|
1919
|
+
getChainId(): number {
|
|
1920
|
+
return CHAIN_IDS[this.config.chain] ?? 0;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Get the USDC contract address for the configured chain.
|
|
1925
|
+
*/
|
|
1926
|
+
getUSDCContract(): string {
|
|
1927
|
+
return USDC_CONTRACTS[this.config.chain];
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Look up a refund result by refund ID.
|
|
1932
|
+
*/
|
|
1933
|
+
getRefund(refundId: string): RefundResult | undefined {
|
|
1934
|
+
return this.refundLedger.get(refundId);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Get all refund results.
|
|
1939
|
+
*/
|
|
1940
|
+
getAllRefunds(): RefundResult[] {
|
|
1941
|
+
return Array.from(this.refundLedger.values());
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Get comprehensive gateway statistics.
|
|
1946
|
+
*/
|
|
1947
|
+
getStats(): {
|
|
1948
|
+
facilitator: ReturnType<X402Facilitator['getStats']>;
|
|
1949
|
+
chainId: number;
|
|
1950
|
+
usdcContract: string;
|
|
1951
|
+
totalRefunds: number;
|
|
1952
|
+
listenerCount: number;
|
|
1953
|
+
} {
|
|
1954
|
+
let listenerCount = 0;
|
|
1955
|
+
for (const listeners of this.listeners.values()) {
|
|
1956
|
+
listenerCount += listeners.size;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return {
|
|
1960
|
+
facilitator: this.facilitator.getStats(),
|
|
1961
|
+
chainId: this.getChainId(),
|
|
1962
|
+
usdcContract: this.getUSDCContract(),
|
|
1963
|
+
totalRefunds: this.refundLedger.size,
|
|
1964
|
+
listenerCount,
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Clean up all resources.
|
|
1970
|
+
*/
|
|
1971
|
+
dispose(): void {
|
|
1972
|
+
this.facilitator.dispose();
|
|
1973
|
+
this.listeners.clear();
|
|
1974
|
+
this.refundLedger.clear();
|
|
1975
|
+
this.eventCounter = 0;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|