Skip to content

Commit eb013e7

Browse files
jolestarclaude
andauthored
Facilitator v2: Add local signing support by reusing EVM_PRIVATE_KEY (#140) (#141)
* Facilitator v2: Add local signing support by reusing EVM_PRIVATE_KEY This change enables facilitator v2 to send transactions on standard RPC providers by using local signing with private keys instead of requiring unlocked accounts on the node. Changes: - V2Config now includes privateKey field from EVM_PRIVATE_KEY env var - FacilitatorConfig: make signer optional, add privateKey field - createWalletClientForNetwork: support privateKey for local signing - Update /supported endpoint to check for either signer or privateKey - All route dependencies updated to pass v2PrivateKey Fixes #140 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Address PR review comments: make signer optional, improve validation Changes: - Make signer parameter optional in createWalletClientForNetwork - Allow either signer or privateKey (not require both) - Support privateKey with or without 0x prefix - Add validation in createWalletClientForNetwork - Update tests to cover new validation logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 497c8b2 commit eb013e7

File tree

11 files changed

+153
-24
lines changed

11 files changed

+153
-24
lines changed

facilitator/src/config.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ export interface FeeClaimConfig {
8181
export interface V2Config {
8282
/** Enable v2 support (requires FACILITATOR_ENABLE_V2=true) */
8383
enabled: boolean;
84-
/** Facilitator signer address for v2 */
84+
/** Facilitator signer address for v2 (optional, will be derived from privateKey if not provided) */
8585
signer?: string;
86+
/** Private key for v2 signing (reuses EVM_PRIVATE_KEY from v1) */
87+
privateKey?: string;
8688
/** Allowed routers per network for v2 (CAIP-2 network IDs) */
8789
allowedRouters?: Record<string, string[]>;
8890
}
@@ -589,10 +591,15 @@ function parseV2Config(): V2Config {
589591
return { enabled: false };
590592
}
591593

592-
// Parse v2 signer (required when v2 is enabled)
594+
// Parse v2 signer (optional, for unlocked account mode)
593595
const signer = process.env.FACILITATOR_V2_SIGNER;
594-
if (!signer) {
595-
throw new Error("FACILITATOR_V2_SIGNER is required when FACILITATOR_ENABLE_V2=true");
596+
597+
// Get private key from v1's EVM_PRIVATE_KEY for local signing
598+
const privateKey = process.env.EVM_PRIVATE_KEY;
599+
600+
// Either signer or privateKey must be provided
601+
if (!signer && !privateKey) {
602+
throw new Error("FACILITATOR_V2_SIGNER or EVM_PRIVATE_KEY is required when FACILITATOR_ENABLE_V2=true");
596603
}
597604

598605
// Parse v2 allowed routers (optional)
@@ -620,6 +627,7 @@ function parseV2Config(): V2Config {
620627
return {
621628
enabled: true,
622629
signer,
630+
privateKey,
623631
allowedRouters,
624632
};
625633
}

facilitator/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ async function main() {
188188
rpcUrls: config.dynamicGasPrice.rpcUrls,
189189
enableV2: config.v2.enabled,
190190
v2Signer: config.v2.signer,
191+
v2PrivateKey: config.v2.privateKey,
191192
allowedRouters: config.v2.allowedRouters,
192193
},
193194
requestBodyLimit: config.server.requestBodyLimit,

facilitator/src/routes/settle.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ export interface SettleRouteDependencies {
4545
rpcUrls?: Record<string, string>;
4646
/** Enable v2 support (requires FACILITATOR_ENABLE_V2=true) */
4747
enableV2?: boolean;
48-
/** Facilitator signer address for v2 */
48+
/** Facilitator signer address for v2 (optional, will be derived from privateKey if not provided) */
4949
v2Signer?: string;
50+
/** Private key for v2 local signing (reuses EVM_PRIVATE_KEY from v1) */
51+
v2PrivateKey?: string;
5052
/** Allowed routers per network for v2 */
5153
allowedRouters?: Record<string, string[]>;
5254
}
@@ -79,6 +81,7 @@ export function createSettleRoutes(
7981
{
8082
enableV2: deps.enableV2,
8183
signer: deps.v2Signer,
84+
privateKey: deps.v2PrivateKey,
8285
allowedRouters: deps.allowedRouters,
8386
rpcUrls: deps.rpcUrls,
8487
}

facilitator/src/routes/supported.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export interface SupportedRouteDependencies {
1616
poolManager: PoolManager;
1717
/** Enable v2 support (requires FACILITATOR_ENABLE_V2=true) */
1818
enableV2?: boolean;
19-
/** Facilitator signer address for v2 */
19+
/** Facilitator signer address for v2 (optional, will be derived from privateKey if not provided) */
2020
v2Signer?: string;
21+
/** Private key for v2 local signing (reuses EVM_PRIVATE_KEY from v1) */
22+
v2PrivateKey?: string;
2123
/** Allowed routers per network for v2 */
2224
allowedRouters?: Record<string, string[]>;
2325
}
@@ -29,7 +31,8 @@ export interface SupportedRouteDependencies {
2931
* @returns true if v2 is available for advertisement
3032
*/
3133
function isV2Available(deps: SupportedRouteDependencies): boolean {
32-
return !!(deps.enableV2 && deps.v2Signer);
34+
// V2 is available when enabled AND has either signer or privateKey (for local signing)
35+
return !!(deps.enableV2 && (deps.v2Signer || deps.v2PrivateKey));
3336
}
3437

3538
/**

facilitator/src/routes/verify.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export interface VerifyRouteDependencies {
3737
rpcUrls?: Record<string, string>;
3838
/** Enable v2 support (requires FACILITATOR_ENABLE_V2=true) */
3939
enableV2?: boolean;
40-
/** Facilitator signer address for v2 */
40+
/** Facilitator signer address for v2 (optional, will be derived from privateKey if not provided) */
4141
v2Signer?: string;
42+
/** Private key for v2 local signing (reuses EVM_PRIVATE_KEY from v1) */
43+
v2PrivateKey?: string;
4244
/** Allowed routers per network for v2 */
4345
allowedRouters?: Record<string, string[]>;
4446
}
@@ -70,6 +72,7 @@ export function createVerifyRoutes(
7072
{
7173
enableV2: deps.enableV2,
7274
signer: deps.v2Signer,
75+
privateKey: deps.v2PrivateKey,
7376
allowedRouters: deps.allowedRouters,
7477
rpcUrls: deps.rpcUrls,
7578
}

facilitator/src/version-dispatcher.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ const logger = getLogger();
2626
export interface VersionDispatcherConfig {
2727
/** Enable v2 support (requires FACILITATOR_ENABLE_V2=true) */
2828
enableV2?: boolean;
29-
/** Facilitator signer address for v2 */
29+
/** Facilitator signer address for v2 (optional, will be derived from privateKey if not provided) */
3030
signer?: string;
31+
/** Private key for v2 local signing (reuses EVM_PRIVATE_KEY from v1) */
32+
privateKey?: string;
3133
/** Allowed routers per network for v2 */
3234
allowedRouters?: Record<string, string[]>;
3335
/** RPC URLs per network for both v1 and v2 */
@@ -73,9 +75,10 @@ export class VersionDispatcher {
7375
private config: VersionDispatcherConfig = {}
7476
) {
7577
// Initialize v2 facilitator if enabled
76-
if (this.config.enableV2 && this.config.signer) {
78+
if (this.config.enableV2 && (this.config.signer || this.config.privateKey)) {
7779
this.v2Facilitator = createRouterSettlementFacilitator({
7880
signer: this.config.signer,
81+
privateKey: this.config.privateKey,
7982
allowedRouters: this.config.allowedRouters,
8083
rpcUrls: this.config.rpcUrls,
8184
});

typescript/packages/facilitator_v2/src/facilitator.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { FacilitatorValidationError, SettlementRouterError } from "./types.js";
1616
import { isSettlementMode, parseSettlementExtra, getNetworkConfig } from "@x402x/core_v2";
1717
import { calculateCommitment } from "@x402x/core_v2";
1818
import { settleWithSettlementRouter, createPublicClientForNetwork, createWalletClientForNetwork, waitForSettlementReceipt } from "./settlement.js";
19-
import { verifyTypedData, parseErc6492Signature } from "viem";
19+
import { verifyTypedData, parseErc6492Signature, privateKeyToAccount } from "viem";
2020

2121
// EIP-712 authorization types for EIP-3009
2222
const authorizationTypes = {
@@ -126,10 +126,19 @@ export class RouterSettlementFacilitator implements SchemeNetworkFacilitator {
126126

127127
/**
128128
* Get signer addresses for the network
129+
* Derives from privateKey if signer address not explicitly provided
129130
*/
130131
getSigners(network: string): string[] {
131132
validateNetwork(network);
132-
return [this.config.signer];
133+
// Use provided signer or derive from private key
134+
if (this.config.signer) {
135+
return [this.config.signer];
136+
}
137+
if (this.config.privateKey) {
138+
const account = privateKeyToAccount(this.config.privateKey as `0x${string}`);
139+
return [account.address];
140+
}
141+
throw new Error("Either signer or privateKey must be provided in FacilitatorConfig");
133142
}
134143

135144
/**
@@ -502,7 +511,13 @@ export class RouterSettlementFacilitator implements SchemeNetworkFacilitator {
502511
payload: PaymentPayload,
503512
requirements: PaymentRequirements
504513
): Promise<SettleResponse> {
505-
const walletClient = createWalletClientForNetwork(requirements.network, this.config.signer, this.config.rpcUrls);
514+
const walletClient = createWalletClientForNetwork(
515+
requirements.network,
516+
this.config.signer,
517+
this.config.rpcUrls,
518+
undefined,
519+
this.config.privateKey
520+
);
506521
const publicClient = createPublicClientForNetwork(requirements.network, this.config.rpcUrls);
507522

508523
try {

typescript/packages/facilitator_v2/src/settlement.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
createPublicClient,
1010
createWalletClient,
1111
http,
12+
privateKeyToAccount,
1213
type PublicClient,
1314
type WalletClient,
1415
type Chain,
1516
type Transport,
17+
type Account,
1618
} from "viem";
1719
import type { SettlementRouterParams, SettleResponse, FacilitatorConfig } from "./types.js";
1820
import { SETTLEMENT_ROUTER_ABI } from "./types.js";
@@ -53,12 +55,15 @@ export function createPublicClientForNetwork(
5355

5456
/**
5557
* Create viem wallet client for a network
58+
* If privateKey is provided, uses local signing (works with standard RPC providers)
59+
* If only signer address is provided, requires node to have the account unlocked
5660
*/
5761
export function createWalletClientForNetwork(
5862
network: string,
59-
signer: Address,
63+
signer?: Address,
6064
rpcUrls?: Record<string, string>,
61-
transport?: Transport
65+
transport?: Transport,
66+
privateKey?: string
6267
): WalletClient {
6368
const networkConfig = getNetworkConfig(network);
6469

@@ -69,8 +74,24 @@ export function createWalletClientForNetwork(
6974
throw new Error(`No RPC URL available for network: ${network}`);
7075
}
7176

77+
// Validate that at least one of signer or privateKey is provided
78+
if (!signer && !privateKey) {
79+
throw new Error("Either signer or privateKey must be provided to create wallet client");
80+
}
81+
82+
// Use private key for local signing if provided, otherwise use signer address
83+
let account: Account | Address;
84+
if (privateKey) {
85+
account = privateKeyToAccount(privateKey as Hex);
86+
} else if (signer) {
87+
account = signer;
88+
} else {
89+
// This should never happen due to the validation above
90+
throw new Error("Failed to create account: neither signer nor privateKey provided");
91+
}
92+
7293
return createWalletClient({
73-
account: signer,
94+
account,
7495
chain: networkConfig as Chain,
7596
transport: transport || http(rpcUrl),
7697
});
@@ -266,7 +287,9 @@ export async function settleWithSettlementRouter(
266287
const walletClient = createWalletClientForNetwork(
267288
paymentRequirements.network,
268289
config.signer,
269-
config.rpcUrls
290+
config.rpcUrls,
291+
undefined,
292+
config.privateKey
270293
);
271294

272295
// Execute settlement

typescript/packages/facilitator_v2/src/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ export interface SettleResponse {
4242
* Configuration for RouterSettlementFacilitator
4343
*/
4444
export interface FacilitatorConfig {
45-
/** Signer address for facilitating settlements */
46-
signer: Address;
45+
/** Signer address for facilitating settlements (optional, will be derived from privateKey if not provided) */
46+
signer?: Address;
47+
/** Private key for local signing (enables sending transactions on standard RPC providers) */
48+
privateKey?: string;
4749
/** Allowed SettlementRouter addresses per network */
4850
allowedRouters?: Record<string, string[]>;
4951
/** Optional RPC URLs per network */

typescript/packages/facilitator_v2/src/validation.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,35 @@ export function validateNetwork(network: string): Network {
155155
*/
156156
export function validateFacilitatorConfig(config: {
157157
signer?: string;
158+
privateKey?: string;
158159
allowedRouters?: Record<string, string[]>;
159160
rpcUrls?: Record<string, string>;
160161
}): void {
161-
if (!config.signer) {
162-
throw new FacilitatorValidationError("Missing signer in facilitator configuration");
162+
// Either signer or privateKey must be provided
163+
if (!config.signer && !config.privateKey) {
164+
throw new FacilitatorValidationError("Missing signer or privateKey in facilitator configuration");
163165
}
164166

165-
if (!isValidEthereumAddress(config.signer)) {
166-
throw new FacilitatorValidationError(`Invalid signer address: ${config.signer}`);
167+
// Validate signer if provided
168+
if (config.signer) {
169+
if (!isValidEthereumAddress(config.signer)) {
170+
throw new FacilitatorValidationError(`Invalid signer address: ${config.signer}`);
171+
}
172+
}
173+
174+
// Validate private key if provided
175+
if (config.privateKey) {
176+
// Private key should be a 32-byte hex string (64 hex chars), with optional 0x prefix
177+
const privateKey = config.privateKey;
178+
const hasPrefix = privateKey.startsWith("0x") || privateKey.startsWith("0X");
179+
const hexBody = hasPrefix ? privateKey.slice(2) : privateKey;
180+
181+
// Validate that it's a valid 64-character hex string (32 bytes)
182+
if (!/^[a-fA-F0-9]{64}$/.test(hexBody)) {
183+
throw new FacilitatorValidationError(
184+
"Invalid private key format: must be 32-byte hex string (64 hex chars, with optional 0x prefix)"
185+
);
186+
}
167187
}
168188

169189
if (config.allowedRouters) {

0 commit comments

Comments
 (0)