Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
20 changes: 10 additions & 10 deletions packages/app/control/docs/components/meta.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"title": "Components",
"pages": [
"index",
"installation",
"echo-account",
"ui-components",
"customization"
],
"icon": "Component"
}
"title": "Components",
"pages": [
"index",
"installation",
"echo-account",
"ui-components",
"customization"
],
"icon": "Component"
}
33 changes: 32 additions & 1 deletion packages/app/server/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { USDC_ADDRESS } from "services/fund-repo/constants";
import { base, baseSepolia } from "viem/chains";
import { Address } from "viem";

export const WALLET_OWNER = process.env.WALLET_OWNER
? `${process.env.WALLET_OWNER}`
: 'echo-fund-owner';
export const WALLET_SMART_ACCOUNT = process.env.WALLET_OWNER + '-smart-account';

export const DOMAIN_NAME = 'USD Coin';
export const DOMAIN_VERSION = '2';
export const DOMAIN_CHAIN_ID = 8453;

export const TRANSFER_WITH_AUTHORIZATION_NAME = 'TransferWithAuthorization';
export const TRANSFER_WITH_AUTHORIZATION_TYPE = {
Expand Down Expand Up @@ -33,3 +36,31 @@ export const X402_VERSION = '1';
export const X402_ERROR_MESSAGE = 'Payment Required';
export const X402_PAYMENT_HEADER = 'x-payment';
export const X402_REALM = 'echo';



// Chain IDs
const BASE_CHAIN_ID = 8453;
const BASE_SEPOLIA_CHAIN_ID = 84532;
const AVALANCHE_FUJI_CHAIN_ID = 43113;
const AVALANCHE_CHAIN_ID = 43114;
const POLYGON_CHAIN_ID = 137;
const POLYGON_AMOY_CHAIN_ID = 80002;

export const NETWORK_TO_CHAIN_ID: Record<string, number> = {
'base': BASE_CHAIN_ID,
'base-sepolia': BASE_SEPOLIA_CHAIN_ID,
'avalanche-fuji': AVALANCHE_FUJI_CHAIN_ID,
'avalanche': AVALANCHE_CHAIN_ID,
'polygon': POLYGON_CHAIN_ID,
'polygon-amoy': POLYGON_AMOY_CHAIN_ID,
};

export const NETWORK_TO_CHAIN = {
'base': base,
'base-sepolia': baseSepolia,
};

export const USDC_ADDRESS_BY_NETWORK: Record<string, Address> = {
'base': USDC_ADDRESS,
};
13 changes: 12 additions & 1 deletion packages/app/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
PaymentPayload,
PaymentRequirementsSchema,
SettleRequestSchema,
ExactEvmPayloadSchema,
} from 'services/facilitator/x402-types';
import { Decimal } from '@prisma/client/runtime/library';
import logger from 'logger';
Expand Down Expand Up @@ -67,7 +68,17 @@ export async function settle(
return undefined;
}

const payload = xPaymentData.payload as ExactEvmPayload;
const parseResult = ExactEvmPayloadSchema.safeParse(xPaymentData.payload);

if (!parseResult.success) {
logger.error('Invalid EVM payload', {
error: parseResult.error.format()
});
buildX402Response(req, res, maxCost);
return undefined;
}

const payload = parseResult.data;
logger.info(`Payment payload: ${JSON.stringify(payload)}`);

const paymentAmount = payload.authorization.value;
Expand Down
200 changes: 200 additions & 0 deletions packages/app/server/src/services/facilitator/evmFacilitator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import {
getAddress,
Hex,
parseErc6492Signature,
Address,
encodeFunctionData,
Abi,
} from 'viem';
import { getNetworkId, getERC20Balance } from './evmUtils';
import {
PaymentPayload,
PaymentRequirements,
VerifyResponse,
SettleResponse,
ExactEvmPayloadSchema,
} from './x402-types';
import { USDC_ADDRESS_BY_NETWORK } from '../../constants';
import { ERC3009_ABI } from '../fund-repo/constants';
import { getSmartAccount } from '../../utils';
import logger from 'logger';

const SCHEME = 'exact';

export async function verify(
payload: PaymentPayload,
paymentRequirements: PaymentRequirements
): Promise<VerifyResponse> {
if (payload.scheme !== SCHEME || paymentRequirements.scheme !== SCHEME) {
return {
isValid: false,
invalidReason: 'unsupported_scheme',
payer: undefined,
};
}

const parseResult = ExactEvmPayloadSchema.safeParse(payload.payload);

if (!parseResult.success) {
return {
isValid: false,
invalidReason: 'invalid_payload',
payer: undefined,
};
}

const exactEvmPayload = parseResult.data;

const network = payload.network;
const chainId = getNetworkId(network);
const erc20Address = paymentRequirements.asset as Address;

if (!chainId) {
return {
isValid: false,
invalidReason: 'invalid_network',
payer: exactEvmPayload.authorization.from,
};
}

if (erc20Address !== USDC_ADDRESS_BY_NETWORK[network]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The USDC address lookup uses a network key that may not exist in the mapping, causing all non-base networks to fail payment validation with an invalid_payment error.

View Details
📝 Patch Details
diff --git a/packages/app/server/src/constants.ts b/packages/app/server/src/constants.ts
index 401f276e..26c60650 100644
--- a/packages/app/server/src/constants.ts
+++ b/packages/app/server/src/constants.ts
@@ -63,4 +63,9 @@ export const NETWORK_TO_CHAIN = {
 
 export const USDC_ADDRESS_BY_NETWORK: Record<string, Address> = {
   'base': USDC_ADDRESS,
+  'base-sepolia': '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
+  'avalanche': '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E',
+  'avalanche-fuji': '0x5425890298aed601595a70AB815c96711a31Bc65',
+  'polygon': '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
+  'polygon-amoy': '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582',
 };
\ No newline at end of file

Analysis

USDC address lookup fails for non-base networks in evmFacilitator.verify()

What fails: evmFacilitator.verify() always returns invalid_payment for avalanche, polygon, base-sepolia, avalanche-fuji, and polygon-amoy networks because USDC_ADDRESS_BY_NETWORK[network] returns undefined

How to reproduce:

// Call verify() with any non-base network payment:
const payload = { network: 'polygon', scheme: 'exact', payload: {...} };
const requirements = { asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' }; // Polygon USDC
const result = await verify(payload, requirements);
// result.invalidReason === 'invalid_payment' 

Result: Line 60 comparison erc20Address !== USDC_ADDRESS_BY_NETWORK[network] evaluates to "0x3c499c..." !== undefined which is always true, causing verification to fail

Expected: Should compare against actual USDC addresses per Circle's official contract addresses for each supported network

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is good. We only support base payments

return {
isValid: false,
invalidReason: 'invalid_payment',
payer: exactEvmPayload.authorization.from,
};
}

// Verify that payment was made to the correct address
if (getAddress(exactEvmPayload.authorization.to) !== getAddress(paymentRequirements.payTo)) {
return {
isValid: false,
invalidReason: 'invalid_exact_evm_payload_recipient_mismatch',
payer: exactEvmPayload.authorization.from,
};
}

// Verify deadline is not yet expired (pad 3 blocks = 6 seconds)
if (
BigInt(exactEvmPayload.authorization.validBefore) < BigInt(Math.floor(Date.now() / 1000) + 6)
) {
return {
isValid: false,
invalidReason: 'invalid_exact_evm_payload_authorization_valid_before',
payer: exactEvmPayload.authorization.from,
};
}

// Verify deadline is not yet valid
if (BigInt(exactEvmPayload.authorization.validAfter) > BigInt(Math.floor(Date.now() / 1000))) {
return {
isValid: false,
invalidReason: 'invalid_exact_evm_payload_authorization_valid_after',
payer: exactEvmPayload.authorization.from,
};
}

// Verify client has enough funds to cover paymentRequirements.maxAmountRequired
const balance = await getERC20Balance(
network,
erc20Address,
exactEvmPayload.authorization.from as Address
);

if (balance < BigInt(paymentRequirements.maxAmountRequired)) {
return {
isValid: false,
invalidReason: 'insufficient_funds',
payer: exactEvmPayload.authorization.from,
};
}

// Verify value in payload is enough to cover paymentRequirements.maxAmountRequired
if (BigInt(exactEvmPayload.authorization.value) < BigInt(paymentRequirements.maxAmountRequired)) {
return {
isValid: false,
invalidReason: 'invalid_exact_evm_payload_authorization_value',
payer: exactEvmPayload.authorization.from,
};
}

return {
isValid: true,
invalidReason: undefined,
payer: exactEvmPayload.authorization.from,
};
}

export async function settle(
paymentPayload: PaymentPayload,
paymentRequirements: PaymentRequirements
): Promise<SettleResponse> {
const valid = await verify(paymentPayload, paymentRequirements);

if (!valid.isValid) {
return {
success: false,
network: paymentPayload.network,
transaction: '',
errorReason: valid.invalidReason ?? 'invalid_scheme',
payer: valid.payer,
};
}

const parseResult = ExactEvmPayloadSchema.safeParse(paymentPayload.payload);

if (!parseResult.success) {
return {
success: false,
network: paymentPayload.network,
transaction: '',
errorReason: 'invalid_payload',
payer: undefined,
};
}

const payload = parseResult.data;

const { signature } = parseErc6492Signature(payload.signature as Hex);

const { smartAccount } = await getSmartAccount();

const callData = encodeFunctionData({
abi: ERC3009_ABI as Abi,
functionName: 'transferWithAuthorization',
args: [
payload.authorization.from as Address,
payload.authorization.to as Address,
BigInt(payload.authorization.value),
BigInt(payload.authorization.validAfter),
BigInt(payload.authorization.validBefore),
payload.authorization.nonce as Hex,
signature,
],
});

const result = await smartAccount.sendUserOperation({
network: paymentPayload.network as 'base' | 'base-sepolia',
calls: [
{
to: paymentRequirements.asset as `0x${string}`,
value: 0n,
data: callData as `0x${string}`,
},
],
});

await smartAccount.waitForUserOperation({
userOpHash: result.userOpHash,
});

logger.info('Settlement transaction completed', { userOpHash: result.userOpHash });

return {
success: true,
transaction: result.userOpHash,
network: paymentPayload.network,
payer: payload.authorization.from,
};
}

40 changes: 40 additions & 0 deletions packages/app/server/src/services/facilitator/evmUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Network } from './x402-types';
import { createPublicClient, http, Address } from 'viem';
import { ERC20_CONTRACT_ABI } from '../fund-repo/constants';
import { NETWORK_TO_CHAIN_ID, NETWORK_TO_CHAIN } from '../../constants';

export function getNetworkId(network: Network): number {
const chainId = NETWORK_TO_CHAIN_ID[network];
if (!chainId) {
throw new Error(`Unsupported network: ${network}`);
}
return chainId;
}

export async function getERC20Balance(
network: Network,
erc20Address: Address,
userAddress: Address
): Promise<bigint> {
const chain = NETWORK_TO_CHAIN[network as keyof typeof NETWORK_TO_CHAIN];
if (!chain) {
throw new Error(`Unsupported network for balance check: ${network}`);
}

const baseRpcUrl = process.env.BASE_RPC_URL || undefined;

const client = createPublicClient({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

public rpc? should we use something more robust ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will swap for a private RPC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is done now

chain,
transport: http(baseRpcUrl),
});

const balance = await client.readContract({
address: erc20Address,
abi: ERC20_CONTRACT_ABI,
functionName: 'balanceOf',
args: [userAddress],
}) as bigint;

return balance;
}

14 changes: 14 additions & 0 deletions packages/app/server/src/services/facilitator/facilitatorRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { generateCdpJwt } from './facilitatorService';
import logger, { logMetric } from '../../logger';
import dotenv from 'dotenv';
import { localFacilitator } from './localFacilitator';

dotenv.config();

Expand Down Expand Up @@ -45,6 +46,11 @@ const facilitators: FacilitatorConfig[] = [
methodPrefix: PAYAI_FACILITATOR_METHOD_PREFIX!,
name: 'PayAI',
},
{
url: '',
methodPrefix: '',
name: 'Local',
},
];

/**
Expand All @@ -67,6 +73,14 @@ export async function facilitatorWithRetry<

for (const facilitator of facilitators) {
try {
if (facilitator.name === 'Local') {
const result = await localFacilitator[method]({
paymentPayload: payload,
paymentRequirements: paymentRequirements,
});
return result as T;
}

const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
Expand Down
Loading
Loading