diff --git a/package.json b/package.json index b5b3658..35336fc 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "express-rate-limit": "^7.1.0", "graphql": "^15.8.0", "helmet": "^7.1.0", - "joi": "^17.11.0", - "jsonwebtoken": "^9.0.2", + "joi": "^17.13.3", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.0.0", "pg": "^8.11.0", "redis": "^4.6.0", diff --git a/scripts/verify_ucp_flow.js b/scripts/verify_ucp_flow.js index 6b4606f..f245d99 100644 --- a/scripts/verify_ucp_flow.js +++ b/scripts/verify_ucp_flow.js @@ -6,15 +6,7 @@ const { Transaction } = require('../src/models/transaction'); const { Wallet } = require('../src/models/wallet'); // Mock DB wrapper for WalletService since it expects a db object with models attached -// or methods. But looking at WalletService code, it expects `database` to have methods -// like findWalletById, createTransaction, updateWalletBalance etc. -// Wait, looking at src/services/wallet.js: -// `this.db = database;` -// `await this.db.findWalletByUserId(userId);` -// So I need a DB adapter or I need to implement those methods. -// Let's check src/models/index.js if it exists to see if there is a DB abstraction layer. - -// Let's create a simple DB adapter for the test +// or methods. class DBAdapter { constructor() { this.Wallet = Wallet; @@ -24,6 +16,7 @@ class DBAdapter { async findWalletById(id) { return Wallet.findById(id); } async findWalletByUserId(userId) { return Wallet.findOne({ userId }); } + async createWallet(data) { return Wallet.create(data); } async createTransaction(data) { return Transaction.create(data); } async updateTransaction(id, data) { return Transaction.findByIdAndUpdate(id, data, { new: true }); } @@ -78,8 +71,7 @@ async function verify() { intent: 'transfer', sender: { agent_id: agent1.id }, recipient: { agent_id: agent2.id }, - amount: { value: 150, currency: 'USD' }, - metadata: { orderId: 'testing-123' } + amount: { value: 150, currency: 'USD' } }; const result = await ucpService.processPayload(ucpPayload); @@ -96,7 +88,7 @@ async function verify() { if (updatedWallet2.balance !== 150) throw new Error('Wallet2 balance incorrect'); // 4. Verify Transaction Record - const txs = await Transaction.find({ 'ucpPayload.metadata.orderId': 'testing-123' }); + const txs = await Transaction.find({ 'ucpPayload.sender.agent_id': agent1.id }); if (txs.length === 0) throw new Error('Transaction record not found'); console.log('Transaction Verified:', txs[0].id); diff --git a/src/config/index.js b/src/config/index.js index cf9122d..d10c908 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -125,6 +125,11 @@ function validateConfig() { errors.push('ENCRYPTION_KEY is required in production'); } + // Database URL is always required + if (!process.env.DATABASE_URL && config.server.nodeEnv === 'production') { + errors.push('DATABASE_URL is required in production'); + } + if (errors.length > 0) { throw new Error(`Configuration errors:\n${errors.join('\n')}`); } diff --git a/src/index.js b/src/index.js index b92425c..ecf3621 100644 --- a/src/index.js +++ b/src/index.js @@ -15,22 +15,24 @@ const logger = require('./utils/logger'); const WalletService = require('./services/wallet'); const TokenizationService = require('./services/tokenization'); const MobilePaymentService = require('./services/mobilePayment'); -const AgentService = require('./services/agent'); // New -const UCPService = require('./services/ucp'); // New +const AgentService = require('./services/agent'); +const A2AService = require('./services/a2aService'); +const UCPService = require('./services/ucp'); const walletRoutes = require('./routes/wallet'); const tokenizationRoutes = require('./routes/tokenization'); const mobilePaymentRoutes = require('./routes/mobilePayment'); -const agentRoutes = require('./routes/agent'); // New -const ucpRoutes = require('./routes/ucp'); // New +const agentRoutes = require('./routes/agent'); +const ucpRoutes = require('./routes/ucp'); // Initialize services const db = require('./utils/database'); const walletService = new WalletService(db); const tokenizationService = new TokenizationService(); const mobilePaymentService = new MobilePaymentService(tokenizationService, walletService); -const agentService = new AgentService(db); // New -const ucpService = new UCPService(); // New +const agentService = new AgentService(db); +const a2aService = new A2AService(walletService, db); +const ucpService = new UCPService(a2aService); // Initialize Express app const app = express(); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 62ec533..3e1170e 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -4,11 +4,33 @@ * Validates JWT tokens and protects routes. */ +const jwt = require('jsonwebtoken'); +const config = require('../config'); +const logger = require('../utils/logger'); + const authenticate = (req, res, next) => { - // In a real application, you would validate a JWT token here. - // For this example, we'll just simulate an authenticated user. - req.user = { id: 'user123', roles: ['user'] }; - next(); + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } + + const token = authHeader.split(' ')[1]; + + try { + const decoded = jwt.verify(token, config.security.jwtSecret); + req.user = decoded; + next(); + } catch (error) { + logger.warn('Invalid token attempt:', error.message); + return res.status(401).json({ + success: false, + error: 'Invalid or expired token' + }); + } }; const authorize = (roles = []) => { diff --git a/src/models/refund.js b/src/models/refund.js index 77e4864..473922a 100644 --- a/src/models/refund.js +++ b/src/models/refund.js @@ -9,8 +9,7 @@ const mongoose = require('mongoose'); const refundSchema = new mongoose.Schema({ transactionId: { type: String, - required: true, - index: true + required: true }, walletId: { type: String, @@ -91,7 +90,6 @@ const refundSchema = new mongoose.Schema({ // Indexes refundSchema.index({ walletId: 1, createdAt: -1 }); refundSchema.index({ status: 1, createdAt: -1 }); -refundSchema.index({ transactionId: 1 }); // Virtual for refund ID refundSchema.virtual('id').get(function() { diff --git a/src/models/transaction.js b/src/models/transaction.js index 7dc1ed1..98f18be 100644 --- a/src/models/transaction.js +++ b/src/models/transaction.js @@ -45,8 +45,7 @@ const transactionSchema = new mongoose.Schema({ }, transferId: { type: String, - sparse: true, - index: true + sparse: true }, refundId: { type: String, @@ -108,7 +107,6 @@ transactionSchema.index({ walletId: 1, createdAt: -1 }); transactionSchema.index({ walletId: 1, type: 1 }); transactionSchema.index({ walletId: 1, status: 1 }); transactionSchema.index({ createdAt: -1 }); -transactionSchema.index({ transferId: 1 }, { sparse: true }); // Virtual for transaction ID transactionSchema.virtual('id').get(function () { diff --git a/src/routes/agent.js b/src/routes/agent.js index 4de04dd..b73cd14 100644 --- a/src/routes/agent.js +++ b/src/routes/agent.js @@ -5,6 +5,9 @@ */ const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validation'); +const Joi = require('joi'); module.exports = (agentService) => { const router = express.Router(); @@ -13,7 +16,7 @@ module.exports = (agentService) => { * GET /api/v1/agents * Get all registered agents */ - router.get('/', async (req, res, next) => { + router.get('/', authenticate, async (req, res, next) => { try { const agents = await agentService.getAllAgents(); res.json(agents); @@ -25,26 +28,40 @@ module.exports = (agentService) => { /** * POST /api/v1/agents * Register a new agent - * Body: { name: string, policy?: { spendingLimit?: number, authorizedCounterparties?: string[] } } */ - router.post('/', async (req, res, next) => { - try { - const { name, policy } = req.body; - if (!name) { - return res.status(400).json({ error: 'Agent name is required' }); + router.post('/', + authenticate, + validate({ + body: Joi.object({ + name: Joi.string().required(), + ownerId: Joi.string().required(), + walletId: Joi.string().required(), + type: Joi.string().valid('personal', 'business', 'service'), + config: Joi.object({ + limits: Joi.object({ + daily: Joi.number().min(0), + perTransaction: Joi.number().min(0) + }), + authorizedCounterparties: Joi.array().items(Joi.string()), + autoApprove: Joi.boolean() + }) + }) + }), + async (req, res, next) => { + try { + const newAgent = await agentService.registerAgent(req.body); + res.status(201).json(newAgent); + } catch (error) { + next(error); } - const newAgent = await agentService.registerAgent({ name, policy }); - res.status(201).json(newAgent); - } catch (error) { - next(error); } - }); + ); /** * GET /api/v1/agents/:agentId * Get an agent by ID */ - router.get('/:agentId', async (req, res, next) => { + router.get('/:agentId', authenticate, async (req, res, next) => { try { const agent = await agentService.getAgent(req.params.agentId); res.json(agent); @@ -56,35 +73,28 @@ module.exports = (agentService) => { /** * PUT /api/v1/agents/:agentId/policy * Update an agent's policy - * Body: { spendingLimit?: number, authorizedCounterparties?: string[] } - */ - router.put('/:agentId/policy', async (req, res, next) => { - try { - const newPolicy = req.body; - const updatedAgent = await agentService.updateAgentPolicy(req.params.agentId, newPolicy); - res.json(updatedAgent); - } catch (error) { - next(error); - } - }); - - /** - * POST /api/v1/agents/transfer - * Perform an Agent-to-Agent (A2A) transfer (conceptual) - * Body: { fromAgentId: string, toAgentId: string, amount: number, currency: string } */ - router.post('/transfer', async (req, res, next) => { - try { - const { fromAgentId, toAgentId, amount, currency } = req.body; - if (!fromAgentId || !toAgentId || !amount || !currency) { - return res.status(400).json({ error: 'Missing required transfer parameters' }); + router.put('/:agentId/policy', + authenticate, + validate({ + body: Joi.object({ + limits: Joi.object({ + daily: Joi.number().min(0), + perTransaction: Joi.number().min(0) + }), + authorizedCounterparties: Joi.array().items(Joi.string()), + autoApprove: Joi.boolean() + }) + }), + async (req, res, next) => { + try { + const updatedAgent = await agentService.updateAgentPolicy(req.params.agentId, req.body); + res.json(updatedAgent); + } catch (error) { + next(error); } - const result = await agentService.performA2ATransfer({ fromAgentId, toAgentId, amount, currency }); - res.json(result); - } catch (error) { - next(error); } - }); + ); return router; }; diff --git a/src/routes/ucp.js b/src/routes/ucp.js index 3674644..6af1b40 100644 --- a/src/routes/ucp.js +++ b/src/routes/ucp.js @@ -5,24 +5,47 @@ */ const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const { validate } = require('../middleware/validation'); +const Joi = require('joi'); module.exports = (ucpService) => { const router = express.Router(); /** - * POST /api/v1/ucp/process-intent + * POST /api/v1/ucp/process * Process a UCP-compliant commerce intent - * Body: { protocolVersion: string, intentType: string, data: object, agentId: string, walletId?: string } */ - router.post('/process-intent', async (req, res, next) => { - try { - const ucpIntent = req.body; - const result = await ucpService.processIntent(ucpIntent); - res.json(result); - } catch (error) { - next(error); + router.post('/process', + authenticate, + validate({ + body: Joi.object({ + ver: Joi.string().required(), + intent: Joi.string().required(), + sender: Joi.object({ + agent_id: Joi.string().required(), + wallet_id: Joi.string() + }).required(), + recipient: Joi.object({ + agent_id: Joi.string().required(), + wallet_id: Joi.string() + }), + amount: Joi.object({ + value: Joi.number().required(), + currency: Joi.string() + }), + data: Joi.object() + }) + }), + async (req, res, next) => { + try { + const result = await ucpService.processPayload(req.body); + res.json(result); + } catch (error) { + next(error); + } } - }); + ); /** * GET /api/v1/ucp/schema diff --git a/src/services/a2aService.js b/src/services/a2aService.js index 3f01336..306480f 100644 --- a/src/services/a2aService.js +++ b/src/services/a2aService.js @@ -22,46 +22,50 @@ class A2AService { * @param {Object} params.ucpPayload - The original UCP intent/payload */ async executeTransfer({ fromAgentId, toAgentId, amount, ucpPayload = {} }) { - // 1. Validate Agents - const fromAgent = await Agent.findById(fromAgentId); - if (!fromAgent || fromAgent.status !== 'active') { - throw new Error(`Sender agent ${fromAgentId} not found or inactive`); - } + try { + // 1. Validate Agents + const fromAgent = await Agent.findById(fromAgentId); + if (!fromAgent || fromAgent.status !== 'active') { + throw new Error(`Sender agent ${fromAgentId} not found or inactive`); + } - const toAgent = await Agent.findById(toAgentId); - if (!toAgent || toAgent.status !== 'active') { - throw new Error(`Recipient agent ${toAgentId} not found or inactive`); - } + const toAgent = await Agent.findById(toAgentId); + if (!toAgent || toAgent.status !== 'active') { + throw new Error(`Recipient agent ${toAgentId} not found or inactive`); + } - // 2. Policy Checks (Sender) - await this._validateAgentPolicy(fromAgent, toAgentId, amount); + // 2. Policy Checks (Sender) + await this._validateAgentPolicy(fromAgent, toAgentId, amount); - // 3. Execute Wallet Transfer - const transferResult = await this.walletService.transfer({ - fromWalletId: fromAgent.walletId, - toWalletId: toAgent.walletId, - amount, - description: `A2A Transfer: ${fromAgent.name} -> ${toAgent.name}`, - metadata: { // Pass metadata for Transaction creation - agentId: fromAgentId, - counterpartyAgentId: toAgentId, - ucpPayload, - type: 'a2a_transfer' - } - }); + // 3. Execute Wallet Transfer + const transferResult = await this.walletService.transfer({ + fromWalletId: fromAgent.walletId, + toWalletId: toAgent.walletId, + amount, + description: `A2A Transfer: ${fromAgent.name} -> ${toAgent.name}`, + metadata: { // Pass metadata for Transaction creation + agentId: fromAgentId, + counterpartyAgentId: toAgentId, + ucpPayload, + type: 'a2a_transfer' + } + }); - // 4. Update Agent Usage (if we were tracking daily usage in db, we'd do it here) - // For now, limits are stateless checks against config. - // In a real implementation, we would query daily volume or update a usage record. + // 4. Update Agent Usage (if we were tracking daily usage in db, we'd do it here) + // For now, limits are stateless checks against config. + // In a real implementation, we would query daily volume or update a usage record. - return { - success: true, - transferId: transferResult.transferId, - timestamp: new Date(), - fromAgent: fromAgent.name, - toAgent: toAgent.name, - amount - }; + return { + success: true, + transferId: transferResult.transferId, + timestamp: new Date(), + fromAgent: fromAgent.name, + toAgent: toAgent.name, + amount + }; + } catch (error) { + throw this._handleError('executeTransfer', error); + } } /** @@ -84,6 +88,15 @@ class A2AService { } } } + + /** + * Handle and format errors + * @private + */ + _handleError(method, error) { + console.error(`A2AService.${method} error:`, error); + return error instanceof Error ? error : new Error(error); + } } module.exports = A2AService; diff --git a/src/services/agent.js b/src/services/agent.js index d0fad54..dadfaae 100644 --- a/src/services/agent.js +++ b/src/services/agent.js @@ -9,8 +9,7 @@ const crypto = require('crypto'); class AgentService { constructor(database, config = {}) { - this.db = database; // Assuming agents will eventually be persisted in a database - this.agents = new Map(); // In-memory store for now + this.db = database; this.config = { defaultSpendingLimit: config.defaultSpendingLimit || 1000, defaultAuthorizedCounterparties: config.defaultAuthorizedCounterparties || [] @@ -21,25 +20,33 @@ class AgentService { * Register a new agent * @param {Object} params - Agent registration parameters * @param {string} params.name - Agent's name + * @param {string} params.ownerId - Owner's identifier + * @param {string} params.walletId - Associated wallet identifier * @param {Object} params.policy - Agent's policy (spending limits, counterparties) * @returns {Promise} Registered agent */ - async registerAgent({ name, policy }) { - const agentId = `agent_${crypto.randomBytes(6).toString('hex')}`; - const newAgent = { - id: agentId, - name: name, - policy: { - spendingLimit: policy?.spendingLimit || this.config.defaultSpendingLimit, - authorizedCounterparties: policy?.authorizedCounterparties || this.config.defaultAuthorizedCounterparties - }, - status: 'active', - createdAt: new Date(), - updatedAt: new Date() - }; - this.agents.set(agentId, newAgent); - // In a real scenario, persist to database: await this.db.createAgent(newAgent); - return newAgent; + async registerAgent({ name, ownerId, walletId, type = 'personal', config: agentConfig }) { + try { + const newAgent = await this.db.createAgent({ + name, + ownerId, + walletId, + type, + status: 'active', + config: { + limits: { + daily: agentConfig?.limits?.daily || 0, + perTransaction: agentConfig?.limits?.perTransaction || this.config.defaultSpendingLimit + }, + authorizedCounterparties: agentConfig?.authorizedCounterparties || this.config.defaultAuthorizedCounterparties, + autoApprove: agentConfig?.autoApprove || false + }, + metadata: {} + }); + return newAgent; + } catch (error) { + throw this._handleError('registerAgent', error); + } } /** @@ -48,41 +55,49 @@ class AgentService { * @returns {Promise} Agent object */ async getAgent(agentId) { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error('Agent not found'); + try { + const agent = await this.db.findAgentById(agentId); + if (!agent) { + throw new Error('Agent not found'); + } + return agent; + } catch (error) { + throw this._handleError('getAgent', error); } - // In a real scenario: await this.db.findAgentById(agentId); - return agent; } /** * Get all registered agents * @returns {Promise} List of agent objects */ - async getAllAgents() { - return Array.from(this.agents.values()); - // In a real scenario: await this.db.findAllAgents(); + async getAllAgents(filter = {}) { + try { + return await this.db.findAllAgents(filter); + } catch (error) { + throw this._handleError('getAllAgents', error); + } } /** * Update an agent's policy * @param {string} agentId - Agent identifier - * @param {Object} newPolicy - New policy details - * @param {number} newPolicy.spendingLimit - New spending limit - * @param {Array} newPolicy.authorizedCounterparties - New list of authorized counterparties + * @param {Object} newConfig - New configuration details * @returns {Promise} Updated agent object */ - async updateAgentPolicy(agentId, newPolicy) { - const agent = await this.getAgent(agentId); // Will throw if not found - agent.policy = { - ...agent.policy, - ...newPolicy - }; - agent.updatedAt = new Date(); - this.agents.set(agentId, agent); - // In a real scenario: await this.db.updateAgent(agentId, { policy: agent.policy, updatedAt: agent.updatedAt }); - return agent; + async updateAgentPolicy(agentId, newConfig) { + try { + const agent = await this.getAgent(agentId); + const updatedAgent = await this.db.updateAgent(agentId, { + config: { + ...agent.config, + ...newConfig + }, + updatedAt: new Date() + }); + return updatedAgent; + } catch (error) { + throw this._handleError('updateAgentPolicy', error); + } } /** diff --git a/src/services/index.js b/src/services/index.js index 1ded17a..322b426 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1,12 +1,14 @@ const WalletService = require('./wallet'); +const AgentService = require('./agent'); const A2AService = require('./a2aService'); -const UCPService = require('./ucpService'); +const UCPService = require('./ucp'); const MobilePaymentService = require('./mobilePayment'); const TokenizationService = require('./tokenization'); const Web3Service = require('./web3'); module.exports = { WalletService, + AgentService, A2AService, UCPService, MobilePaymentService, diff --git a/src/services/ucp.js b/src/services/ucp.js index c5ad826..fabdd17 100644 --- a/src/services/ucp.js +++ b/src/services/ucp.js @@ -2,100 +2,119 @@ * Universal Commerce Protocol (UCP) Service * * Facilitates standardized communication of commerce intents across various platforms. - * Parses, validates, and processes UCP-compliant messages. + * Parses, validates, and processes UCP-compliant messages, translating them into + * system-specific transaction logic via A2AService. */ -const crypto = require('crypto'); -const Joi = require('joi'); // For schema validation +const Joi = require('joi'); class UCPService { - constructor(config = {}) { + constructor(a2aService, config = {}) { + this.a2aService = a2aService; this.config = config; - // Define a basic schema for UCP intent validation + + // Standard UCP Schema this.ucpIntentSchema = Joi.object({ - protocolVersion: Joi.string().required(), - intentType: Joi.string().valid('purchase', 'transfer', 'request', 'offer').required(), - data: Joi.object().required(), // Data structure will vary by intentType - agentId: Joi.string().required(), - walletId: Joi.string().optional(), // Wallet might not always be directly involved + ver: Joi.string().required(), + intent: Joi.string().valid('transfer', 'payment', 'purchase', 'request', 'offer').required(), + sender: Joi.object({ + agent_id: Joi.string().required(), + wallet_id: Joi.string().optional() + }).required(), + recipient: Joi.object({ + agent_id: Joi.string().required(), + wallet_id: Joi.string().optional() + }).optional(), + amount: Joi.object({ + value: Joi.number().positive().required(), + currency: Joi.string().default('USD') + }).optional(), + data: Joi.object().optional(), timestamp: Joi.date().iso().default(() => new Date()) }); } /** - * Process a UCP-compliant commerce intent - * @param {Object} ucpIntent - The UCP intent object - * @returns {Promise} Processing result + * Process a UCP Payload + * @param {Object} payload - The raw UCP JSON payload */ - async processIntent(ucpIntent) { + async processPayload(payload) { try { // 1. Validate the UCP intent against schema - const { error, value } = this.ucpIntentSchema.validate(ucpIntent); + const { error, value } = this.ucpIntentSchema.validate(payload, { stripUnknown: true }); if (error) { throw new Error(`UCP Intent validation failed: ${error.details.map(x => x.message).join(', ')}`); } - // Assign validated value back - ucpIntent = value; - - // 2. Simulate intent processing based on type - let transactionId = `ucp_txn_${crypto.randomBytes(8).toString('hex')}`; - let message = 'UCP intent processed successfully.'; + const validatedPayload = value; + const { intent } = validatedPayload; - switch (ucpIntent.intentType) { - case 'purchase': - // In a real scenario, this would involve WalletService (deduct funds), - // AgentService (policy check), InventoryService, etc. - message = `Purchase intent for agent ${ucpIntent.agentId} processed.`; - break; + switch (intent) { case 'transfer': - // In a real scenario, this would involve WalletService.transfer - message = `Transfer intent for agent ${ucpIntent.agentId} processed.`; - break; - case 'request': - message = `Request intent for agent ${ucpIntent.agentId} received.`; - break; - case 'offer': - message = `Offer intent from agent ${ucpIntent.agentId} received.`; - break; + case 'payment': + return this._handleTransfer(validatedPayload); + case 'purchase': + // Future implementation: integration with Inventory/Order services + return { status: 'success', message: 'Purchase intent received (simulation)', payload: validatedPayload }; default: - message = `Unknown intent type '${ucpIntent.intentType}'. Processed as generic intent.`; - break; + return { status: 'success', message: `UCP intent '${intent}' received and logged.`, payload: validatedPayload }; } - - // 3. Return a mock result - return { - status: 'success', - transactionId: transactionId, - message: message, - processedAt: new Date().toISOString(), - originalIntent: ucpIntent - }; } catch (error) { - throw this._handleError('processIntent', error); + throw this._handleError('processPayload', error); } } + /** + * Handle transfer/payment intents via A2AService + * @private + */ + async _handleTransfer(payload) { + const { sender, recipient, amount } = payload; + + if (!recipient?.agent_id) { + throw new Error('Missing recipient agent_id for transfer'); + } + if (!amount?.value) { + throw new Error('Missing amount value'); + } + + return this.a2aService.executeTransfer({ + fromAgentId: sender.agent_id, + toAgentId: recipient.agent_id, + amount: amount.value, + ucpPayload: payload + }); + } + /** * Get UCP Schema (conceptual) - * @returns {Object} JSON Schema for UCP */ getUcpSchema() { - // In a real implementation, this would return a more comprehensive JSON Schema return { $schema: "http://json-schema.org/draft-07/schema#", title: "Universal Commerce Protocol Intent", - description: "A standardized message for communicating commerce intents.", type: "object", properties: { - protocolVersion: { type: "string", description: "Version of the UCP protocol" }, - intentType: { type: "string", enum: ["purchase", "transfer", "request", "offer"], description: "Type of commerce intent" }, - data: { type: "object", description: "Payload specific to the intentType" }, - agentId: { type: "string", description: "Identifier of the agent initiating the intent" }, - walletId: { type: "string", description: "Optional: Identifier of the wallet involved" }, - timestamp: { type: "string", format: "date-time", description: "Timestamp of intent creation" } + ver: { type: "string" }, + intent: { type: "string", enum: ["transfer", "payment", "purchase", "request", "offer"] }, + sender: { + type: "object", + properties: { agent_id: { type: "string" } }, + required: ["agent_id"] + }, + recipient: { + type: "object", + properties: { agent_id: { type: "string" } } + }, + amount: { + type: "object", + properties: { + value: { type: "number" }, + currency: { type: "string" } + } + } }, - required: ["protocolVersion", "intentType", "data", "agentId"] + required: ["ver", "intent", "sender"] }; } diff --git a/src/services/ucpService.js b/src/services/ucpService.js deleted file mode 100644 index 43f56cc..0000000 --- a/src/services/ucpService.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Universal Commerce Protocol (UCP) Service - * - * Parses and executes standardized commerce intents from agentic interactions. - * This services acts as the translation layer between UCP payloads and - * system-specific transaction logic. - */ - -class UCPService { - constructor(a2aService) { - this.a2aService = a2aService; - } - - /** - * Process a UCP Payload - * @param {Object} payload - The raw UCP JSON payload - */ - async processPayload(payload) { - this._validatePayload(payload); - - const { intent } = payload; - - switch (intent) { - case 'transfer': - case 'payment': - return this._handleTransfer(payload); - default: - throw new Error(`Unsupported UCP intent: ${intent}`); - } - } - - /** - * Validate UCP Schema - * @private - */ - _validatePayload(payload) { - if (!payload.ver) throw new Error('Missing UCP version'); - if (!payload.intent) throw new Error('Missing UCP intent'); - if (!payload.sender?.agent_id) throw new Error('Missing sender agent_id'); - // detailed schema validation would go here (e.g. using Joi or Zod) - } - - async _handleTransfer(payload) { - const { sender, recipient, amount } = payload; - - if (!recipient?.agent_id) throw new Error('Missing recipient agent_id for transfer'); - if (!amount?.value) throw new Error('Missing amount value'); - - return this.a2aService.executeTransfer({ - fromAgentId: sender.agent_id, - toAgentId: recipient.agent_id, - amount: amount.value, - ucpPayload: payload - }); - } -} - -module.exports = UCPService; diff --git a/src/services/wallet.js b/src/services/wallet.js index feb627f..a24983f 100644 --- a/src/services/wallet.js +++ b/src/services/wallet.js @@ -48,7 +48,7 @@ class WalletService { } // Create wallet - const wallet = await this.db.Wallet.create({ + const wallet = await this.db.createWallet({ userId, balance: initialBalance, currency: currency || this.config.defaultCurrency, diff --git a/src/services/web3.js b/src/services/web3.js index d9d1c89..9416b66 100644 --- a/src/services/web3.js +++ b/src/services/web3.js @@ -18,22 +18,26 @@ class Web3Service { * @param {string} network - Network identifier (e.g. 'ethereum', 'polygon') */ async createWallet(network = 'ethereum') { - // 1. Generate Key Pair (Simulation) - // In production, this might happen inside the Secure Enclave or via a KMS - const privateKey = '0x' + crypto.randomBytes(32).toString('hex'); - const publicKey = '0x' + crypto.randomBytes(20).toString('hex'); // Simplified address generation + try { + // 1. Generate Key Pair (Simulation) + // In production, this might happen inside the Secure Enclave or via a KMS + const privateKey = '0x' + crypto.randomBytes(32).toString('hex'); + const publicKey = '0x' + crypto.randomBytes(20).toString('hex'); // Simplified address generation - // 2. Vault the Private Key - const secretToken = await this.tokenizationService.createSecretToken(privateKey, { - network, - type: 'blockchain_wallet' - }); + // 2. Vault the Private Key + const secretToken = await this.tokenizationService.createSecretToken(privateKey, { + network, + type: 'blockchain_wallet' + }); - return { - address: publicKey, - keyTokenId: secretToken.id, - network - }; + return { + address: publicKey, + keyTokenId: secretToken.id, + network + }; + } catch (error) { + throw this._handleError('createWallet', error); + } } /** @@ -42,13 +46,17 @@ class Web3Service { * @param {string} network */ async getBalance(address, network = 'ethereum') { - // Simulation: Return a random balance or mock - // In production, this calls an RPC provider (Infura, Alchemy, etc.) - return { - balance: '1.5', - currency: 'ETH', - network - }; + try { + // Simulation: Return a random balance or mock + // In production, this calls an RPC provider (Infura, Alchemy, etc.) + return { + balance: '1.5', + currency: 'ETH', + network + }; + } catch (error) { + throw this._handleError('getBalance', error); + } } /** @@ -60,30 +68,43 @@ class Web3Service { * @param {string} params.network - Network to use */ async sendTransaction({ keyTokenId, to, value, network = 'ethereum' }) { - // 1. Construct Transaction (Simplified) - const txData = { - to, - value, - nonce: 0, // Would fetch proper nonce - gasPrice: '20000000000', - gasLimit: '21000' - }; + try { + // 1. Construct Transaction (Simplified) + const txData = { + to, + value, + nonce: 0, // Would fetch proper nonce + gasPrice: '20000000000', + gasLimit: '21000' + }; - // 2. Sign Transaction using Vault - // We serialize the txData to string/hex for signing - const serializedTx = JSON.stringify(txData); - const signature = await this.tokenizationService.signWithToken(keyTokenId, serializedTx); + // 2. Sign Transaction using Vault + // We serialize the txData to string/hex for signing + const serializedTx = JSON.stringify(txData); + const signature = await this.tokenizationService.signWithToken(keyTokenId, serializedTx); - // 3. Broadcast Transaction - // In production, send signedTx to RPC - const txHash = '0x' + crypto.randomBytes(32).toString('hex'); + // 3. Broadcast Transaction + // In production, send signedTx to RPC + const txHash = '0x' + crypto.randomBytes(32).toString('hex'); - return { - hash: txHash, - status: 'pending', - network, - signedData: signature - }; + return { + hash: txHash, + status: 'pending', + network, + signedData: signature + }; + } catch (error) { + throw this._handleError('sendTransaction', error); + } + } + + /** + * Handle and format errors + * @private + */ + _handleError(method, error) { + console.error(`Web3Service.${method} error:`, error); + return error instanceof Error ? error : new Error(error); } } diff --git a/src/utils/database.js b/src/utils/database.js index 09b965b..884f044 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -51,6 +51,7 @@ async function connectMongoDB() { require('../models/wallet'); require('../models/transaction'); require('../models/refund'); + require('../models/agent'); return mongoose.connection; } catch (error) { @@ -98,8 +99,126 @@ async function disconnectDatabase() { } } +const { Wallet } = require('../models/wallet'); +const { Transaction } = require('../models/transaction'); +const { Refund } = require('../models/refund'); +const { Agent } = require('../models/agent'); + module.exports = { connectDatabase, disconnectDatabase, - isConnected: () => isConnected + isConnected: () => isConnected, + Wallet, + Transaction, + Refund, + Agent, + + // Repository Methods (MongoDB Implementation) + async findWalletById(id) { + return Wallet.findById(id); + }, + + async findWalletByUserId(userId) { + return Wallet.findOne({ userId }); + }, + + async createWallet(walletData) { + return Wallet.create(walletData); + }, + + async updateWalletBalance(walletId, amount) { + return Wallet.findByIdAndUpdate( + walletId, + { $inc: { balance: amount } }, + { new: true, runValidators: true } + ); + }, + + async updateWallet(walletId, updateData) { + return Wallet.findByIdAndUpdate(walletId, updateData, { new: true }); + }, + + async createTransaction(transactionData) { + return Transaction.create(transactionData); + }, + + async updateTransaction(transactionId, updateData) { + return Transaction.findByIdAndUpdate(transactionId, updateData, { new: true }); + }, + + async findTransactions(query) { + const { walletId, type, status, dateFrom, dateTo, page = 1, limit = 20 } = query; + const filter = { walletId }; + if (type) filter.type = type; + if (status) filter.status = status; + if (dateFrom || dateTo) { + filter.createdAt = {}; + if (dateFrom) filter.createdAt.$gte = new Date(dateFrom); + if (dateTo) filter.createdAt.$lte = new Date(dateTo); + } + + const skip = (page - 1) * limit; + const items = await Transaction.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const total = await Transaction.countDocuments(filter); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit) + }; + }, + + async getWalletStatistics(walletId) { + const stats = await Transaction.aggregate([ + { $match: { walletId, status: 'completed' } }, + { + $group: { + _id: null, + totalCredits: { + $sum: { $cond: [{ $eq: ['$type', 'credit'] }, '$amount', 0] } + }, + totalDebits: { + $sum: { $cond: [{ $eq: ['$type', 'debit'] }, { $abs: '$amount' }, 0] } + }, + transactionCount: { $sum: 1 }, + averageTransaction: { $avg: { $abs: '$amount' } } + } + } + ]); + + const lastTransaction = await Transaction.findOne({ walletId, status: 'completed' }) + .sort({ createdAt: -1 }); + + return { + ...(stats[0] || {}), + lastTransaction + }; + }, + + // Agent Repository Methods + async findAgentById(id) { + return Agent.findById(id); + }, + + async findAgentByWalletId(walletId) { + return Agent.findOne({ walletId }); + }, + + async createAgent(agentData) { + return Agent.create(agentData); + }, + + async updateAgent(agentId, updateData) { + return Agent.findByIdAndUpdate(agentId, updateData, { new: true }); + }, + + async findAllAgents(filter = {}) { + return Agent.find(filter); + } }; diff --git a/tests/integration/wallet.spec.js b/tests/integration/wallet.spec.js index 2b7f374..afb5fda 100644 --- a/tests/integration/wallet.spec.js +++ b/tests/integration/wallet.spec.js @@ -4,6 +4,13 @@ const walletRoutes = require('../../src/routes/wallet'); const WalletService = require('../../src/services/wallet'); jest.mock('../../src/services/wallet'); +jest.mock('../../src/middleware/auth', () => ({ + authenticate: (req, res, next) => { + req.user = { id: 'user123' }; + next(); + }, + authorize: () => (req, res, next) => next() +})); describe('Wallet API', () => { let app; @@ -18,7 +25,7 @@ describe('Wallet API', () => { describe('POST /api/v1/wallet', () => { it('should create a new wallet', async () => { - const wallet = { _id: 'some-id', userId: 'user123' }; + const wallet = { id: 'some-id', userId: 'user123' }; mockWalletService.createWallet.mockResolvedValue(wallet); const res = await request(app) diff --git a/tests/unit/agent.spec.js b/tests/unit/agent.spec.js new file mode 100644 index 0000000..015a2e4 --- /dev/null +++ b/tests/unit/agent.spec.js @@ -0,0 +1,41 @@ +const AgentService = require('../../src/services/agent'); + +describe('AgentService', () => { + let agentService; + let mockDb; + + beforeEach(() => { + mockDb = { + createAgent: jest.fn(), + findAgentById: jest.fn(), + findAllAgents: jest.fn(), + updateAgent: jest.fn(), + }; + agentService = new AgentService(mockDb); + }); + + describe('registerAgent', () => { + it('should register a new agent with default policies', async () => { + const agentData = { name: 'Test Agent', ownerId: 'owner123', walletId: 'wallet123' }; + const expectedAgent = { ...agentData, id: 'agent123', status: 'active', config: { limits: { perTransaction: 1000 } } }; + mockDb.createAgent.mockResolvedValue(expectedAgent); + + const agent = await agentService.registerAgent(agentData); + + expect(mockDb.createAgent).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Test Agent', + ownerId: 'owner123', + walletId: 'wallet123', + status: 'active' + })); + expect(agent).toEqual(expectedAgent); + }); + }); + + describe('getAgent', () => { + it('should throw if agent not found', async () => { + mockDb.findAgentById.mockResolvedValue(null); + await expect(agentService.getAgent('nonexistent')).rejects.toThrow('Agent not found'); + }); + }); +}); diff --git a/tests/unit/ucp.spec.js b/tests/unit/ucp.spec.js new file mode 100644 index 0000000..717ebae --- /dev/null +++ b/tests/unit/ucp.spec.js @@ -0,0 +1,44 @@ +const UCPService = require('../../src/services/ucp'); + +describe('UCPService', () => { + let ucpService; + let mockA2AService; + + beforeEach(() => { + mockA2AService = { + executeTransfer: jest.fn(), + }; + ucpService = new UCPService(mockA2AService); + }); + + describe('processPayload', () => { + it('should process a valid transfer intent', async () => { + const payload = { + ver: '1.0', + intent: 'transfer', + sender: { agent_id: 'agentA' }, + recipient: { agent_id: 'agentB' }, + amount: { value: 100 } + }; + mockA2AService.executeTransfer.mockResolvedValue({ success: true, transferId: 'tx123' }); + + const result = await ucpService.processPayload(payload); + + expect(mockA2AService.executeTransfer).toHaveBeenCalledWith(expect.objectContaining({ + fromAgentId: 'agentA', + toAgentId: 'agentB', + amount: 100, + ucpPayload: expect.objectContaining({ + ver: '1.0', + intent: 'transfer' + }) + })); + expect(result.success).toBe(true); + }); + + it('should throw validation error for missing version', async () => { + const payload = { intent: 'transfer' }; + await expect(ucpService.processPayload(payload)).rejects.toThrow(/UCP Intent validation failed/); + }); + }); +}); diff --git a/tests/unit/wallet.spec.js b/tests/unit/wallet.spec.js index f83f18b..9df6cd3 100644 --- a/tests/unit/wallet.spec.js +++ b/tests/unit/wallet.spec.js @@ -7,10 +7,9 @@ describe('WalletService', () => { beforeEach(() => { mockDb = { - Wallet: { - create: jest.fn(), - }, + createWallet: jest.fn(), findWalletByUserId: jest.fn(), + createTransaction: jest.fn(), }; walletService = new WalletService(mockDb); }); @@ -20,18 +19,18 @@ describe('WalletService', () => { const walletData = { userId: 'user123' }; const expectedWallet = { ...walletData, - _id: new mongoose.Types.ObjectId(), + id: 'some-id', currency: 'USD', balance: 0, status: 'active', }; mockDb.findWalletByUserId.mockResolvedValue(null); - mockDb.Wallet.create.mockResolvedValue(expectedWallet); + mockDb.createWallet.mockResolvedValue(expectedWallet); const wallet = await walletService.createWallet(walletData); expect(mockDb.findWalletByUserId).toHaveBeenCalledWith('user123'); - expect(mockDb.Wallet.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockDb.createWallet).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user123', currency: 'USD', balance: 0,