Skip to content

Conversation

@manu-xmint
Copy link
Collaborator

refactor: remove Coinbase facilitator coupling and implement Faremeter

Summary

This PR refactors the x402 webhook node to remove hard coupling to Coinbase's facilitator API and implements Faremeter as the new facilitator. The changes maintain the x402 protocol schema while providing a cleaner abstraction layer for future facilitator implementations.

Key changes:

  • Created IFacilitator interface to abstract facilitator implementations
  • Implemented FaremeterFacilitator class with Faremeter API integration
  • Replaced Coinbase-specific credentials (apiKeyId/apiKeySecret) with generic facilitatorApiKey
  • Removed Coinbase-specific code (CoinbaseJWT.ts, coinbaseFacilitator.ts)
  • Updated webhook execution logic to use facilitator abstraction
  • EVM-only implementation (no Solana changes as requested)

Architecture:

Webhooks.ts → IFacilitator (interface) → FaremeterFacilitator (implementation)

Review & Testing Checklist for Human

⚠️ CRITICAL - Untested API Integration:
This implementation is based on Faremeter documentation but has NOT been tested against the actual Faremeter API. Please verify:

  • Faremeter API endpoints are correct - Verify api.faremeter.com, /v1/x402/verify, and /v1/x402/settle match actual Faremeter API
  • Authentication mechanism works - Confirm Bearer token authentication with facilitatorApiKey is correct format
  • End-to-end payment flow - Test complete webhook flow: receive x-payment header → verify payment → settle payment
  • x402 protocol compliance - Verify request/response format matches x402 spec and Faremeter's actual API responses
  • Error handling - Test error scenarios (invalid credentials, payment verification failures, network errors)

Additional considerations:

  • Breaking change impact - Existing users with Coinbase credentials will need to reconfigure. Consider migration strategy.
  • Response format - Verify Faremeter returns { isValid, invalidReason } for verify and { success, transaction: { hash }, errorReason } for settle

Test Plan Recommendation

  1. Set up test webhook with Faremeter API credentials
  2. Send test x402 payment request with valid x-payment header
  3. Verify payment verification and settlement complete successfully
  4. Test error scenarios (invalid payment, expired payment, insufficient amount)
  5. Monitor console logs for API request/response details

Notes

…r integration

- Created IFacilitator interface to abstract facilitator implementations
- Implemented FaremeterFacilitator class following the interface
- Updated Webhooks.ts to use facilitator abstraction instead of direct Coinbase calls
- Replaced Coinbase-specific credentials (apiKeyId/apiKeySecret) with generic facilitatorApiKey
- Removed CoinbaseJWT.ts and coinbaseFacilitator.ts (Coinbase-specific code)
- Maintained x402 protocol schema compatibility
- EVM-only implementation (no Solana changes)

Co-Authored-By: [email protected] <[email protected]>
@devin-ai-integration
Copy link
Contributor

Original prompt from [email protected]
In the `Crossmint/n8n-nodes-crossmint` repository, specifically in the `feature/x402-node` branch, please perform the following tasks:

- Refactor the code to remove coupling to a specific facilitator API. The scheme should remain consistent with the Coinbase x402 protocol's schema.
- Change the existing Coinbase facilitator implementation to use Faremeter.

- Avoid introducing any regressions or unintended changes to the existing functionality.
- Refer to the documentation at faremeter-docs.txt attached for guidance.
- Remember you only have to do it for EVM. We don't support Solana.


Submit your work as a pull request. Make sure CI passes.
ATTACHMENT:"https://app.devin.ai/attachments/2f73a4ff-8be0-4e81-89cd-468a028dd40a/faremeter-docs.txt"

You only need to look in the following repo: Crossmint/n8n-nodes-crossmint

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm learning! Leave a 👍 or a 👎 to help me learn. Extra points if you leave a comment telling me why i'm wrong tagging @greptileai


6 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines 17 to 96
const paymentRequirementsObj: IPaymentRequirements = {
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
maxAmountRequired: paymentRequirements.maxAmountRequired,
resource: paymentRequirements.resource,
description: paymentRequirements.description,
mimeType: paymentRequirements.mimeType,
outputSchema: paymentRequirements.outputSchema,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
asset: paymentRequirements.asset,
extra: paymentRequirements.extra,
};

const requestBody = {
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
paymentPayload: {
...paymentPayload,
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
},
paymentRequirements: paymentRequirementsObj,
};

const requestDataStr = JSON.stringify(requestBody, null, 2);

console.log(`=== SENDING TO FAREMETER FACILITATOR (VERIFY) ===\n${requestDataStr}`);

const res = await fetch(`https://${FAREMETER_HOST}${FACILITATOR_VERIFY_PATH}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify(requestBody),
});

const responseText = await res.text();

console.log(`=== RAW RESPONSE FROM FAREMETER FACILITATOR (VERIFY) ===`);
console.log(`Status Code: ${res.status}`);
console.log(`Status Text: ${res.statusText}`);
console.log(`Raw Response Body:`, responseText);
console.log(`Response Headers:`, JSON.stringify(Object.fromEntries(res.headers.entries()), null, 2));

const receivedLog = `=== RECEIVED FROM FAREMETER FACILITATOR (VERIFY) ===\nStatus: ${res.status} ${res.statusText}\nResponse: ${responseText}`;
console.log(receivedLog);

if (!res.ok) {
throw new Error(`/verify ${res.status}: ${responseText}`);
}

return JSON.parse(responseText) as { isValid: boolean; invalidReason?: string };
}

async settlePayment(
paymentPayload: IPaymentPayload,
paymentRequirements: PaymentRequirements,
): Promise<{ success: boolean; txHash?: string; error?: string }> {
const paymentRequirementsObj: IPaymentRequirements = {
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
maxAmountRequired: paymentRequirements.maxAmountRequired,
resource: paymentRequirements.resource,
description: paymentRequirements.description,
mimeType: paymentRequirements.mimeType,
outputSchema: paymentRequirements.outputSchema,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
asset: paymentRequirements.asset,
extra: paymentRequirements.extra,
};

const requestBody = {
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
paymentPayload: {
...paymentPayload,
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
},
paymentRequirements: paymentRequirementsObj,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Significant code duplication between verifyPayment and settlePayment methods - identical logic for building paymentRequirementsObj and requestBody is repeated. Extract helper function to improve readability and maintainability.

Suggested change
const paymentRequirementsObj: IPaymentRequirements = {
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
maxAmountRequired: paymentRequirements.maxAmountRequired,
resource: paymentRequirements.resource,
description: paymentRequirements.description,
mimeType: paymentRequirements.mimeType,
outputSchema: paymentRequirements.outputSchema,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
asset: paymentRequirements.asset,
extra: paymentRequirements.extra,
};
const requestBody = {
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
paymentPayload: {
...paymentPayload,
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
},
paymentRequirements: paymentRequirementsObj,
};
const requestDataStr = JSON.stringify(requestBody, null, 2);
console.log(`=== SENDING TO FAREMETER FACILITATOR (VERIFY) ===\n${requestDataStr}`);
const res = await fetch(`https://${FAREMETER_HOST}${FACILITATOR_VERIFY_PATH}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify(requestBody),
});
const responseText = await res.text();
console.log(`=== RAW RESPONSE FROM FAREMETER FACILITATOR (VERIFY) ===`);
console.log(`Status Code: ${res.status}`);
console.log(`Status Text: ${res.statusText}`);
console.log(`Raw Response Body:`, responseText);
console.log(`Response Headers:`, JSON.stringify(Object.fromEntries(res.headers.entries()), null, 2));
const receivedLog = `=== RECEIVED FROM FAREMETER FACILITATOR (VERIFY) ===\nStatus: ${res.status} ${res.statusText}\nResponse: ${responseText}`;
console.log(receivedLog);
if (!res.ok) {
throw new Error(`/verify ${res.status}: ${responseText}`);
}
return JSON.parse(responseText) as { isValid: boolean; invalidReason?: string };
}
async settlePayment(
paymentPayload: IPaymentPayload,
paymentRequirements: PaymentRequirements,
): Promise<{ success: boolean; txHash?: string; error?: string }> {
const paymentRequirementsObj: IPaymentRequirements = {
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
maxAmountRequired: paymentRequirements.maxAmountRequired,
resource: paymentRequirements.resource,
description: paymentRequirements.description,
mimeType: paymentRequirements.mimeType,
outputSchema: paymentRequirements.outputSchema,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
asset: paymentRequirements.asset,
extra: paymentRequirements.extra,
};
const requestBody = {
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
paymentPayload: {
...paymentPayload,
x402Version: typeof paymentPayload.x402Version === 'string' ? parseInt(paymentPayload.x402Version, 10) : paymentPayload.x402Version ?? 1,
},
paymentRequirements: paymentRequirementsObj,
};
private buildRequestBody(
paymentPayload: IPaymentPayload,
paymentRequirements: PaymentRequirements,
) {
const paymentRequirementsObj: IPaymentRequirements = {
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
maxAmountRequired: paymentRequirements.maxAmountRequired,
resource: paymentRequirements.resource,
description: paymentRequirements.description,
mimeType: paymentRequirements.mimeType,
outputSchema: paymentRequirements.outputSchema,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
asset: paymentRequirements.asset,
extra: paymentRequirements.extra,
};
const x402Version = typeof paymentPayload.x402Version === 'string'
? parseInt(paymentPayload.x402Version, 10)
: paymentPayload.x402Version ?? 1;
return {
x402Version,
paymentPayload: {
...paymentPayload,
x402Version,
},
paymentRequirements: paymentRequirementsObj,
};
}
async verifyPayment(
paymentPayload: IPaymentPayload,
paymentRequirements: PaymentRequirements,
): Promise<{ isValid: boolean; invalidReason?: string }> {
const requestBody = this.buildRequestBody(paymentPayload, paymentRequirements);

Context Used: Rule from dashboard - When a function grows to 80+ lines, extract helper functions to improve readability and maintainabil... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/webhooks/executions/facilitator/faremeterFacilitator.ts
Line: 17:96

Comment:
**style:** Significant code duplication between `verifyPayment` and `settlePayment` methods - identical logic for building `paymentRequirementsObj` and `requestBody` is repeated. Extract helper function to improve readability and maintainability.

```suggestion
	private buildRequestBody(
		paymentPayload: IPaymentPayload,
		paymentRequirements: PaymentRequirements,
	) {
		const paymentRequirementsObj: IPaymentRequirements = {
			scheme: paymentRequirements.scheme,
			network: paymentRequirements.network,
			maxAmountRequired: paymentRequirements.maxAmountRequired,
			resource: paymentRequirements.resource,
			description: paymentRequirements.description,
			mimeType: paymentRequirements.mimeType,
			outputSchema: paymentRequirements.outputSchema,
			payTo: paymentRequirements.payTo,
			maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
			asset: paymentRequirements.asset,
			extra: paymentRequirements.extra,
		};

		const x402Version = typeof paymentPayload.x402Version === 'string' 
			? parseInt(paymentPayload.x402Version, 10) 
			: paymentPayload.x402Version ?? 1;

		return {
			x402Version,
			paymentPayload: {
				...paymentPayload,
				x402Version,
			},
			paymentRequirements: paymentRequirementsObj,
		};
	}

	async verifyPayment(
		paymentPayload: IPaymentPayload,
		paymentRequirements: PaymentRequirements,
	): Promise<{ isValid: boolean; invalidReason?: string }> {
		const requestBody = this.buildRequestBody(paymentPayload, paymentRequirements);
```

**Context Used:** Rule from `dashboard` - When a function grows to 80+ lines, extract helper functions to improve readability and maintainabil... ([source](https://app.greptile.com/review/custom-context?memory=16f48b22-d2d2-42d7-bc6e-3f38c5979688))

How can I resolve this? If you propose a fix, please make it concise.

- Renamed webhooks directory to CrossmintWebhook to satisfy node-dirname-against-convention
- Renamed CrossmintWebhooks.node.ts to CrossmintWebhook.node.ts to satisfy node-filename-against-convention
- Replaced 'any' types with 'unknown' in paymentHelpers.ts, x402Types.ts, and paymentValidation.ts
- Removed unused catch block variables in paymentValidation.ts
- Updated package.json to reference new node path
- All changes are mechanical lint fixes with no functional changes

Co-Authored-By: [email protected] <[email protected]>
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm learning! Leave a 👍 or a 👎 to help me learn. Extra points if you leave a comment telling me why i'm wrong tagging @greptileai


Additional Comments (1)

  1. nodes/CrossmintWebhook/executions/Webhooks.ts, line 136-166 (link)

    style: Nested try-catch blocks - the inner try-catch (lines 136-166) is nested within the outer try-catch (lines 90-183). Refactor using promise chaining for cleaner error handling.

    		const settleResponse = await facilitator.settlePayment(
    			decodedXPaymentJson,
    			verification.paymentRequirements!,
    		).catch((error) => {
    			this.logger.error('Error in x402 webhook settlement, moving on...', error);
    			return { success: true, txHash: 'TBD' };
    		});
    
    		if (!settleResponse.success) {
    			resp.writeHead(402, { 'Content-Type': 'application/json' });
    			resp.end(
    				JSON.stringify({
    					x402Version: 1,
    					accepts: paymentRequirements,
    				}),
    			);
    			return { noWebhookResponse: true };
    		}
    
    		return generateResponse(
    			this,
    			req,
    			responseMode,
    			responseData,
    			settleResponse.txHash ?? 'UNKNOWN_TX',
    			prepareOutput,
    			decodedXPaymentJson.network,
    		);
    

    Context Used: Rule from dashboard - Avoid double-nesting try/catch blocks. Instead, use promise chains with .catch() methods to handle... (source)

11 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Faremeter does not require API keys - trust comes from cryptographic
signatures in the payment payload (EIP-712/EIP-3009), not server credentials.

Changes:
- Removed apiKey parameter from FaremeterFacilitator constructor
- Removed Authorization header from verify/settle API calls
- Removed facilitatorApiKey field from CrossmintApi credentials
- Updated Webhooks.ts to instantiate FaremeterFacilitator without API key
- Added Accept header to Faremeter API requests for proper content negotiation

Co-Authored-By: [email protected] <[email protected]>
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm learning! Leave a 👍 or a 👎 to help me learn. Extra points if you leave a comment telling me why i'm wrong tagging @greptileai


3 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 40 to 48
console.log(`=== SENDING TO FAREMETER FACILITATOR (VERIFY) ===\n${requestDataStr}`);

const res = await fetch(`https://${FAREMETER_HOST}${FACILITATOR_VERIFY_PATH}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(requestBody),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing authentication - no API key or authorization header sent to Faremeter API. The old Coinbase implementation required apiKeyId/apiKeySecret credentials and sent Authorization: Bearer ${token} headers, but this implementation sends no authentication at all. Either Faremeter requires no authentication (unlikely for a payment API) or this is a critical bug that will cause all API requests to fail with 401/403 errors.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/facilitator/faremeterFacilitator.ts
Line: 40:48

Comment:
**logic:** Missing authentication - no API key or authorization header sent to Faremeter API. The old Coinbase implementation required `apiKeyId`/`apiKeySecret` credentials and sent `Authorization: Bearer ${token}` headers, but this implementation sends no authentication at all. Either Faremeter requires no authentication (unlikely for a payment API) or this is a critical bug that will cause all API requests to fail with 401/403 errors.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 38 to +39

// Coinbase credentials (apiKeyId/apiKeySecret) are required for x402 processing
const coinbaseKeyId = (credentials as any).apiKeyId as string | undefined;
const coinbaseKeySecret = (credentials as any).apiKeySecret as string | undefined;

if (!coinbaseKeyId || !coinbaseKeySecret) {
resp.writeHead(403);
resp.end('crossmintApi credential missing Coinbase apiKeyId or apiKeySecret');
return { noWebhookResponse: true };
}
const facilitator = new FaremeterFacilitator();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Facilitator instantiated without credentials - FaremeterFacilitator is created with no parameters, but the Faremeter API likely requires authentication (the old Coinbase implementation passed apiKeyId and apiKeySecret). The credentials are available in scope but not passed to the facilitator. Either add a facilitatorApiKey field to credentials and pass it here, or document why Faremeter requires no authentication.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/Webhooks.ts
Line: 38:39

Comment:
**logic:** Facilitator instantiated without credentials - `FaremeterFacilitator` is created with no parameters, but the Faremeter API likely requires authentication (the old Coinbase implementation passed `apiKeyId` and `apiKeySecret`). The credentials are available in scope but not passed to the facilitator. Either add a `facilitatorApiKey` field to credentials and pass it here, or document why Faremeter requires no authentication.

How can I resolve this? If you propose a fix, please make it concise.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm learning! Leave a 👍 or a 👎 to help me learn. Extra points if you leave a comment telling me why i'm wrong tagging @greptileai


3 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm learning! Leave a 👍 or a 👎 to help me learn. Extra points if you leave a comment telling me why i'm wrong tagging @greptileai


1 file reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

import type { IPaymentPayload, PaymentRequirements } from '../types/x402Types';
import type { IFacilitator } from './IFacilitator';

const FAREMETER_BASE_URL = 'http://localhost:3000/api/v1';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Hardcoded localhost:3000 will fail in production - should be configurable production URL

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/facilitator/faremeterFacilitator.ts
Line: 4:4

Comment:
**logic:** Hardcoded `localhost:3000` will fail in production - should be configurable production URL

How can I resolve this? If you propose a fix, please make it concise.

): Promise<{ isValid: boolean; invalidReason?: string }> {
// Format for your local Faremeter API
const requestBody = {
facilitatorUrl: 'https://facilitator.corbits.dev',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Hardcoded facilitatorUrl should be configurable or derived from environment

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/facilitator/faremeterFacilitator.ts
Line: 17:17

Comment:
**logic:** Hardcoded `facilitatorUrl` should be configurable or derived from environment

How can I resolve this? If you propose a fix, please make it concise.

// Format for your local Faremeter API settle endpoint
const requestBody = {
paytoAddress: paymentRequirements.payTo,
facilitatorUrl: 'https://facilitator.corbits.dev',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Hardcoded facilitatorUrl should be configurable or derived from environment

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/facilitator/faremeterFacilitator.ts
Line: 102:102

Comment:
**logic:** Hardcoded `facilitatorUrl` should be configurable or derived from environment

How can I resolve this? If you propose a fix, please make it concise.

network: paymentRequirements.network,
asset: paymentRequirements.asset,
resource: paymentRequirements.resource,
paymentAmount: parseFloat(paymentRequirements.maxAmountRequired) / 1000000, // Convert atomic units to USDC
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Hardcoded division by 1000000 assumes USDC with 6 decimals - breaks if asset is a different token with different decimals

Prompt To Fix With AI
This is a comment left during a code review.
Path: nodes/CrossmintWebhook/executions/facilitator/faremeterFacilitator.ts
Line: 107:107

Comment:
**logic:** Hardcoded division by `1000000` assumes USDC with 6 decimals - breaks if `asset` is a different token with different decimals

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants