Skip to content

Commit 8b9a43a

Browse files
authored
Merge pull request #631 from Merit-Systems/br/local-facilitator
Local Facilitation as Facilitator of last resort
2 parents 43058e1 + ad9b203 commit 8b9a43a

9 files changed

Lines changed: 422 additions & 120 deletions

File tree

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
2-
"title": "Components",
3-
"pages": [
4-
"index",
5-
"installation",
6-
"echo-account",
7-
"ui-components",
8-
"customization"
9-
],
10-
"icon": "Component"
11-
}
2+
"title": "Components",
3+
"pages": [
4+
"index",
5+
"installation",
6+
"echo-account",
7+
"ui-components",
8+
"customization"
9+
],
10+
"icon": "Component"
11+
}

packages/app/server/src/constants.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { USDC_ADDRESS } from "services/fund-repo/constants";
2+
import { base, baseSepolia } from "viem/chains";
3+
import { Address } from "viem";
4+
15
export const WALLET_OWNER = process.env.WALLET_OWNER
26
? `${process.env.WALLET_OWNER}`
37
: 'echo-fund-owner';
48
export const WALLET_SMART_ACCOUNT = process.env.WALLET_OWNER + '-smart-account';
59

610
export const DOMAIN_NAME = 'USD Coin';
711
export const DOMAIN_VERSION = '2';
8-
export const DOMAIN_CHAIN_ID = 8453;
912

1013
export const TRANSFER_WITH_AUTHORIZATION_NAME = 'TransferWithAuthorization';
1114
export const TRANSFER_WITH_AUTHORIZATION_TYPE = {
@@ -33,3 +36,31 @@ export const X402_VERSION = '1';
3336
export const X402_ERROR_MESSAGE = 'Payment Required';
3437
export const X402_PAYMENT_HEADER = 'x-payment';
3538
export const X402_REALM = 'echo';
39+
40+
41+
42+
// Chain IDs
43+
const BASE_CHAIN_ID = 8453;
44+
const BASE_SEPOLIA_CHAIN_ID = 84532;
45+
const AVALANCHE_FUJI_CHAIN_ID = 43113;
46+
const AVALANCHE_CHAIN_ID = 43114;
47+
const POLYGON_CHAIN_ID = 137;
48+
const POLYGON_AMOY_CHAIN_ID = 80002;
49+
50+
export const NETWORK_TO_CHAIN_ID: Record<string, number> = {
51+
'base': BASE_CHAIN_ID,
52+
'base-sepolia': BASE_SEPOLIA_CHAIN_ID,
53+
'avalanche-fuji': AVALANCHE_FUJI_CHAIN_ID,
54+
'avalanche': AVALANCHE_CHAIN_ID,
55+
'polygon': POLYGON_CHAIN_ID,
56+
'polygon-amoy': POLYGON_AMOY_CHAIN_ID,
57+
};
58+
59+
export const NETWORK_TO_CHAIN = {
60+
'base': base,
61+
'base-sepolia': baseSepolia,
62+
};
63+
64+
export const USDC_ADDRESS_BY_NETWORK: Record<string, Address> = {
65+
'base': USDC_ADDRESS,
66+
};

packages/app/server/src/handlers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
PaymentPayload,
2121
PaymentRequirementsSchema,
2222
SettleRequestSchema,
23+
ExactEvmPayloadSchema,
2324
} from 'services/facilitator/x402-types';
2425
import { Decimal } from '@prisma/client/runtime/library';
2526
import logger from 'logger';
@@ -67,7 +68,17 @@ export async function settle(
6768
return undefined;
6869
}
6970

70-
const payload = xPaymentData.payload as ExactEvmPayload;
71+
const parseResult = ExactEvmPayloadSchema.safeParse(xPaymentData.payload);
72+
73+
if (!parseResult.success) {
74+
logger.error('Invalid EVM payload', {
75+
error: parseResult.error.format()
76+
});
77+
buildX402Response(req, res, maxCost);
78+
return undefined;
79+
}
80+
81+
const payload = parseResult.data;
7182
logger.info(`Payment payload: ${JSON.stringify(payload)}`);
7283

7384
const paymentAmount = payload.authorization.value;
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import {
2+
getAddress,
3+
Hex,
4+
parseErc6492Signature,
5+
Address,
6+
encodeFunctionData,
7+
Abi,
8+
parseUnits,
9+
formatUnits,
10+
} from 'viem';
11+
import { getNetworkId, getERC20Balance, getEthereumBalance } from './evmUtils';
12+
import {
13+
PaymentPayload,
14+
PaymentRequirements,
15+
VerifyResponse,
16+
SettleResponse,
17+
ExactEvmPayloadSchema,
18+
} from './x402-types';
19+
import { USDC_ADDRESS_BY_NETWORK } from '../../constants';
20+
import { ERC3009_ABI } from '../fund-repo/constants';
21+
import { getSmartAccount } from '../../utils';
22+
import logger, { logMetric } from 'logger';
23+
24+
const SCHEME = 'exact';
25+
26+
export async function verify(
27+
payload: PaymentPayload,
28+
paymentRequirements: PaymentRequirements
29+
): Promise<VerifyResponse> {
30+
if (payload.scheme !== SCHEME || paymentRequirements.scheme !== SCHEME) {
31+
return {
32+
isValid: false,
33+
invalidReason: 'unsupported_scheme',
34+
payer: undefined,
35+
};
36+
}
37+
38+
const parseResult = ExactEvmPayloadSchema.safeParse(payload.payload);
39+
40+
if (!parseResult.success) {
41+
return {
42+
isValid: false,
43+
invalidReason: 'invalid_payload',
44+
payer: undefined,
45+
};
46+
}
47+
48+
const exactEvmPayload = parseResult.data;
49+
50+
const network = payload.network;
51+
const chainId = getNetworkId(network);
52+
const erc20Address = paymentRequirements.asset as Address;
53+
54+
if (!chainId) {
55+
return {
56+
isValid: false,
57+
invalidReason: 'invalid_network',
58+
payer: exactEvmPayload.authorization.from,
59+
};
60+
}
61+
62+
if (erc20Address !== USDC_ADDRESS_BY_NETWORK[network]) {
63+
return {
64+
isValid: false,
65+
invalidReason: 'invalid_payment',
66+
payer: exactEvmPayload.authorization.from,
67+
};
68+
}
69+
70+
// Verify that payment was made to the correct address
71+
if (getAddress(exactEvmPayload.authorization.to) !== getAddress(paymentRequirements.payTo)) {
72+
return {
73+
isValid: false,
74+
invalidReason: 'invalid_exact_evm_payload_recipient_mismatch',
75+
payer: exactEvmPayload.authorization.from,
76+
};
77+
}
78+
79+
// Verify deadline is not yet expired (pad 3 blocks = 6 seconds)
80+
if (
81+
BigInt(exactEvmPayload.authorization.validBefore) < BigInt(Math.floor(Date.now() / 1000) + 6)
82+
) {
83+
return {
84+
isValid: false,
85+
invalidReason: 'invalid_exact_evm_payload_authorization_valid_before',
86+
payer: exactEvmPayload.authorization.from,
87+
};
88+
}
89+
90+
// Verify deadline is not yet valid
91+
if (BigInt(exactEvmPayload.authorization.validAfter) > BigInt(Math.floor(Date.now() / 1000))) {
92+
return {
93+
isValid: false,
94+
invalidReason: 'invalid_exact_evm_payload_authorization_valid_after',
95+
payer: exactEvmPayload.authorization.from,
96+
};
97+
}
98+
99+
// Verify client has enough funds to cover paymentRequirements.maxAmountRequired
100+
const balance = await getERC20Balance(
101+
network,
102+
erc20Address,
103+
exactEvmPayload.authorization.from as Address
104+
);
105+
106+
if (balance < BigInt(paymentRequirements.maxAmountRequired)) {
107+
return {
108+
isValid: false,
109+
invalidReason: 'insufficient_funds',
110+
payer: exactEvmPayload.authorization.from,
111+
};
112+
}
113+
114+
// Verify value in payload is enough to cover paymentRequirements.maxAmountRequired
115+
if (BigInt(exactEvmPayload.authorization.value) < BigInt(paymentRequirements.maxAmountRequired)) {
116+
return {
117+
isValid: false,
118+
invalidReason: 'invalid_exact_evm_payload_authorization_value',
119+
payer: exactEvmPayload.authorization.from,
120+
};
121+
}
122+
123+
return {
124+
isValid: true,
125+
invalidReason: undefined,
126+
payer: exactEvmPayload.authorization.from,
127+
};
128+
}
129+
130+
export async function settle(
131+
paymentPayload: PaymentPayload,
132+
paymentRequirements: PaymentRequirements
133+
): Promise<SettleResponse> {
134+
const valid = await verify(paymentPayload, paymentRequirements);
135+
136+
if (!valid.isValid) {
137+
return {
138+
success: false,
139+
network: paymentPayload.network,
140+
transaction: '',
141+
errorReason: valid.invalidReason ?? 'invalid_scheme',
142+
payer: valid.payer,
143+
};
144+
}
145+
146+
const parseResult = ExactEvmPayloadSchema.safeParse(paymentPayload.payload);
147+
148+
if (!parseResult.success) {
149+
return {
150+
success: false,
151+
network: paymentPayload.network,
152+
transaction: '',
153+
errorReason: 'invalid_payload',
154+
payer: undefined,
155+
};
156+
}
157+
158+
const payload = parseResult.data;
159+
160+
const { signature } = parseErc6492Signature(payload.signature as Hex);
161+
162+
const { smartAccount } = await getSmartAccount();
163+
164+
const ETH_WARNING_THRESHOLD = parseUnits(
165+
String(process.env.ETH_WARNING_THRESHOLD || '0.0001'),
166+
18 // ETH decimals
167+
);
168+
169+
const ethereumBalance = await getEthereumBalance(paymentPayload.network, smartAccount.address);
170+
logger.info('Ethereum balance', {
171+
balance: ethereumBalance,
172+
address: smartAccount.address,
173+
});
174+
if (ethereumBalance < ETH_WARNING_THRESHOLD) {
175+
const ethBalanceFormatted = formatUnits(ethereumBalance, 18);
176+
const readableEthWarningThreshold = formatUnits(ETH_WARNING_THRESHOLD, 18);
177+
178+
logger.warn(
179+
`Ethereum balance is less than ${readableEthWarningThreshold} ETH`,
180+
{
181+
balance: ethBalanceFormatted,
182+
threshold: readableEthWarningThreshold,
183+
address: smartAccount.address,
184+
}
185+
);
186+
187+
logMetric('server_wallet.ethereum_balance_running_low', 1, {
188+
amount: ethBalanceFormatted,
189+
address: smartAccount.address,
190+
});
191+
192+
return {
193+
success: false,
194+
network: paymentPayload.network,
195+
transaction: '',
196+
errorReason: 'insufficient_funds',
197+
payer: valid.payer,
198+
};
199+
}
200+
201+
const callData = encodeFunctionData({
202+
abi: ERC3009_ABI as Abi,
203+
functionName: 'transferWithAuthorization',
204+
args: [
205+
payload.authorization.from as Address,
206+
payload.authorization.to as Address,
207+
BigInt(payload.authorization.value),
208+
BigInt(payload.authorization.validAfter),
209+
BigInt(payload.authorization.validBefore),
210+
payload.authorization.nonce as Hex,
211+
signature,
212+
],
213+
});
214+
215+
const result = await smartAccount.sendUserOperation({
216+
network: paymentPayload.network as 'base' | 'base-sepolia',
217+
calls: [
218+
{
219+
to: paymentRequirements.asset as `0x${string}`,
220+
value: 0n,
221+
data: callData as `0x${string}`,
222+
},
223+
],
224+
});
225+
226+
await smartAccount.waitForUserOperation({
227+
userOpHash: result.userOpHash,
228+
});
229+
230+
logger.info('Settlement transaction completed', { userOpHash: result.userOpHash });
231+
232+
return {
233+
success: true,
234+
transaction: result.userOpHash,
235+
network: paymentPayload.network,
236+
payer: payload.authorization.from,
237+
};
238+
}
239+

0 commit comments

Comments
 (0)