Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion backend/src/agents/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Types } from 'mongoose';
import { Revision, Project, AgentAction } from '../models';
import { processRevisionWithCodeCritic, CodeCriticOutput } from './code-critic';
import { notifyClientOfVerifiedWork } from './mediator';
import { triggerAutomaticPayment } from './payment';

export interface CoordinatorResult {
success: boolean;
Expand Down Expand Up @@ -190,7 +191,7 @@ export async function coordinateRevisionReview(
$set: { status: 'IN_REVIEW' }
});

// If visible to client, trigger Mediator
// If visible to client, trigger Mediator and Payment Agent
if (visibleToClient) {
console.log(`🔔 Score >= 8/10 - Triggering Mediator Agent...`);
agentChain.push('MEDIATOR');
Expand All @@ -201,6 +202,18 @@ export async function coordinateRevisionReview(
} else {
console.log(`⚠️ Mediator notification failed: ${notifyResult.error}`);
}

// Trigger automatic payment via Payment Agent
console.log(`💰 Score >= 8/10 - Triggering Payment Agent for automatic payment...`);
agentChain.push('PAYMENT_AGENT');

const paymentResult = await triggerAutomaticPayment(revisionId);
if (paymentResult.success) {
console.log(`✅ Automatic payment released: $${paymentResult.amount} USDC (Stream: ${paymentResult.streamId})`);
} else {
console.log(`⚠️ Automatic payment failed: ${paymentResult.error}`);
// Don't fail the entire flow if payment fails - it can be retried manually
}
} else {
console.log(`📝 Score ${Math.round(combinedScore * 10)}/10 below threshold - feedback sent to freelancer only`);
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/agents/critic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function runCriticAgent(
const filesFormatted = files.map(f =>
`### File: ${f.filename} (${f.language})\n\`\`\`${f.language}\n${f.content}\n\`\`\``
).join('\n\n');
console.log(requirements.structured_brief);

const userPrompt = `
## PROJECT REQUIREMENTS
Expand Down Expand Up @@ -81,6 +82,7 @@ Evaluate this submission against the requirements. Check:
3. Are there any bugs or security issues?
4. Does it follow best practices for the tech stack?


Score strictly - only 0.8+ should reach the client.
Respond with the JSON schema specified.`;

Expand Down
1 change: 1 addition & 0 deletions backend/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { runCriticAgent, processRevisionWithCritic } from './critic';
export { runCodeCriticAgent, processRevisionWithCodeCritic } from './code-critic';
export { runMediatorAgent, notifyClientOfVerifiedWork } from './mediator';
export { coordinateRevisionReview } from './coordinator';
export { triggerAutomaticPayment } from './payment';

// Export types
export type { ArchitectOutput } from './architect';
Expand Down
150 changes: 150 additions & 0 deletions backend/src/agents/payment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Project, Revision, PaymentLedger, User } from '../models';
import { releasePayment } from '../services/coinbase-x402';
import { Types } from 'mongoose';

export interface AutomaticPaymentResult {
success: boolean;
amount?: number;
streamId?: string;
error?: string;
}

/**
* Payment Agent - Automatically releases payment when work passes quality threshold
*
* This agent triggers automatic payment release when:
* - Revision score >= 0.8 (visible_to_client = true)
* - Both client and freelancer have wallets connected
* - Project has escrow funds available
*
* Payment calculation strategy:
* - First milestone (score >= 0.8): 50% of budget
* - Subsequent milestones: 25% of budget each
* - Final payment when project completed: remaining balance
*/
export async function triggerAutomaticPayment(
revisionId: string
): Promise<AutomaticPaymentResult> {
try {
const revision = await Revision.findById(revisionId);
if (!revision) {
return { success: false, error: 'Revision not found' };
}

// Only trigger for work that's visible to client (score >= 0.8)
if (!revision.visible_to_client) {
return { success: false, error: 'Work does not meet quality threshold (score < 8/10)' };
}

const project = await Project.findById(revision.project_id);
if (!project) {
return { success: false, error: 'Project not found' };
}

// Get client and freelancer
const client = await User.findById(project.client_id);
const freelancer = await User.findById(project.freelancer_id);

if (!client || !client.wallet_address) {
return { success: false, error: 'Client wallet not connected. Payment cannot be automated.' };
}

if (!freelancer || !freelancer.wallet_address) {
return { success: false, error: 'Freelancer wallet not connected. Payment cannot be automated.' };
}

// Check if payment already released for this revision
const existingPayment = await PaymentLedger.findOne({
project_id: project._id,
transaction_type: 'MILESTONE_RELEASE',
'notes': { $regex: `revision.*${revision.revision_number}` }
});

if (existingPayment) {
return { success: false, error: 'Payment already released for this revision' };
}

// Calculate payment amount based on milestone strategy
const remainingBudget = project.budget_usdc - (project.released_usdc || 0);
if (remainingBudget <= 0) {
return { success: false, error: 'No remaining budget for payment' };
}

// Determine payment amount based on revision number and remaining budget
let paymentAmount: number;
const isFirstMilestone = (project.released_usdc || 0) === 0;
const isFinalMilestone = project.status === 'COMPLETED' || project.status === 'APPROVED';

if (isFinalMilestone) {
// Final payment: release all remaining budget
paymentAmount = remainingBudget;
} else if (isFirstMilestone) {
// First milestone: 50% of total budget
paymentAmount = Math.min(project.budget_usdc * 0.5, remainingBudget);
} else {
// Subsequent milestones: 25% of total budget (or remaining if less)
paymentAmount = Math.min(project.budget_usdc * 0.25, remainingBudget);
}

// Round to 2 decimal places
paymentAmount = Math.round(paymentAmount * 100) / 100;

if (paymentAmount <= 0) {
return { success: false, error: 'Calculated payment amount is zero' };
}

// Check if sufficient escrow balance
if (paymentAmount > remainingBudget) {
return {
success: false,
error: `Insufficient escrow balance. Available: $${remainingBudget}, Requested: $${paymentAmount}`
};
}

console.log(`💰 Payment Agent: Auto-releasing $${paymentAmount} USDC for revision #${revision.revision_number} (score: ${Math.round(revision.critic_score * 10)}/10)`);

// Initiate x402 payment release
const paymentResponse = await releasePayment(
paymentAmount,
freelancer.wallet_address,
client.wallet_address,
project._id.toString()
);

// Create ledger entry
const ledgerEntry = await PaymentLedger.create({
project_id: project._id,
transaction_type: 'MILESTONE_RELEASE',
amount_usdc: paymentAmount,
x402_stream_id: paymentResponse.streamId,
x402_facilitator_url: paymentResponse.facilitatorUrl,
status: paymentResponse.status,
triggered_by: 'PAYMENT_AGENT',
notes: `Automatic payment for revision #${revision.revision_number} (score: ${Math.round(revision.critic_score * 10)}/10)`
});

// Update project
const newReleasedAmount = (project.released_usdc || 0) + paymentAmount;
project.released_usdc = newReleasedAmount;

if (newReleasedAmount >= project.budget_usdc) {
project.payment_status = 'RELEASED';
} else {
project.payment_status = 'PENDING_RELEASE';
}
await project.save();

console.log(`✅ Payment Agent: Payment released successfully. Stream ID: ${paymentResponse.streamId}`);

return {
success: true,
amount: paymentAmount,
streamId: paymentResponse.streamId
};

} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Payment Agent error:', errorMessage);
return { success: false, error: errorMessage };
}
}
4 changes: 4 additions & 0 deletions backend/src/models/PaymentLedger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface IPaymentLedger extends Document {
triggered_by: 'PAYMENT_AGENT' | 'CLIENT_APPROVAL' | 'MANUAL';
created_at: Date;
confirmed_at?: Date;
notes?: string;
}

const PaymentLedgerSchema = new Schema<IPaymentLedger>({
Expand Down Expand Up @@ -75,6 +76,9 @@ const PaymentLedgerSchema = new Schema<IPaymentLedger>({
},
confirmed_at: {
type: Date
},
notes: {
type: String
}
});

Expand Down
66 changes: 37 additions & 29 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,25 +161,24 @@ router.post('/link-wallet', async (req: Request, res: Response): Promise<void> =

const { wallet_address, cdp_user_id, wallet_network } = req.body;

// Validation
if (!wallet_address) {
res.status(400).json({ error: 'Wallet address is required' });
return;
}

// Validate wallet address format (Ethereum address)
if (!/^0x[a-fA-F0-9]{40}$/.test(wallet_address)) {
res.status(400).json({ error: 'Invalid wallet address format' });
return;
}
// Allow disconnecting wallet by sending empty/null wallet_address
const isDisconnecting = !wallet_address || wallet_address === '';

if (!isDisconnecting) {
// Validate wallet address format (Ethereum address) only if connecting
if (!/^0x[a-fA-F0-9]{40}$/.test(wallet_address)) {
res.status(400).json({ error: 'Invalid wallet address format' });
return;
}

// Validate network if provided
if (wallet_network && !['base-sepolia', 'base', 'ethereum-sepolia', 'ethereum'].includes(wallet_network)) {
res.status(400).json({
error: 'Invalid network',
allowed: ['base-sepolia', 'base', 'ethereum-sepolia', 'ethereum']
});
return;
// Validate network if provided
if (wallet_network && !['base-sepolia', 'base', 'ethereum-sepolia', 'ethereum'].includes(wallet_network)) {
res.status(400).json({
error: 'Invalid network',
allowed: ['base-sepolia', 'base', 'ethereum-sepolia', 'ethereum']
});
return;
}
}

// Find user
Expand All @@ -189,20 +188,29 @@ router.post('/link-wallet', async (req: Request, res: Response): Promise<void> =
return;
}

// Check if wallet is already linked to another account
const existingWallet = await User.findOne({
wallet_address: wallet_address.toLowerCase(),
_id: { $ne: user._id }
});
if (existingWallet) {
res.status(409).json({ error: 'This wallet is already linked to another account' });
return;
// Check if wallet is already linked to another account (only if connecting, not disconnecting)
if (!isDisconnecting) {
const existingWallet = await User.findOne({
wallet_address: wallet_address.toLowerCase(),
_id: { $ne: user._id }
});
if (existingWallet) {
res.status(409).json({ error: 'This wallet is already linked to another account' });
return;
}
}

// Update user with wallet information
user.wallet_address = wallet_address.toLowerCase();
if (cdp_user_id) user.cdp_user_id = cdp_user_id;
if (wallet_network) user.wallet_network = wallet_network;
// Allow disconnecting wallet by sending empty string
if (isDisconnecting) {
user.wallet_address = undefined;
user.cdp_user_id = undefined;
user.wallet_network = undefined;
} else {
user.wallet_address = wallet_address.toLowerCase();
if (cdp_user_id) user.cdp_user_id = cdp_user_id;
if (wallet_network) user.wallet_network = wallet_network;
}

await user.save();

Expand Down
Loading