@x402r/refund 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -0
- package/dist/cjs/index.d.ts +253 -0
- package/dist/cjs/index.js +713 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.d.mts +253 -0
- package/dist/esm/index.mjs +678 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +68 -0
- package/src/facilitator.ts +778 -0
- package/src/index.ts +119 -0
- package/src/server/computeRelayAddress.ts +73 -0
- package/src/server.ts +334 -0
- package/src/types.ts +74 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refund Helper Extension for x402
|
|
3
|
+
*
|
|
4
|
+
* Enables merchants to route payments to DepositRelay contracts via escrow,
|
|
5
|
+
* providing refund and dispute resolution capabilities.
|
|
6
|
+
*
|
|
7
|
+
* ## Overview
|
|
8
|
+
*
|
|
9
|
+
* The refund helper allows merchants to mark payment options as refundable,
|
|
10
|
+
* which routes payments through DepositRelay contracts into escrow accounts.
|
|
11
|
+
* This enables dispute resolution and refunds even if merchants are uncooperative.
|
|
12
|
+
*
|
|
13
|
+
* ## For Merchants (Server-Side)
|
|
14
|
+
*
|
|
15
|
+
* ### Step 1: Mark Payment Options as Refundable
|
|
16
|
+
*
|
|
17
|
+
* Use `refundable()` to mark payment options that should support refunds:
|
|
18
|
+
*
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { refundable } from '@x402r/extensions/refund';
|
|
21
|
+
*
|
|
22
|
+
* const option = refundable({
|
|
23
|
+
* scheme: 'exact',
|
|
24
|
+
* payTo: '0xmerchant123...', // Your merchant payout address
|
|
25
|
+
* price: '$0.01',
|
|
26
|
+
* network: 'eip155:84532',
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ### Step 2: Process Routes with DepositRelay
|
|
31
|
+
*
|
|
32
|
+
* Use `withRefund()` to process route configurations and route refundable
|
|
33
|
+
* payments to DepositRelay:
|
|
34
|
+
*
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import { refundable, withRefund } from '@x402r/extensions/refund';
|
|
37
|
+
*
|
|
38
|
+
* const FACTORY_ADDRESS = '0xFactory123...'; // Any CREATE3-compatible factory
|
|
39
|
+
* const CREATEX_ADDRESS = '0xCreateX123...'; // CreateX contract address
|
|
40
|
+
*
|
|
41
|
+
* const routes = {
|
|
42
|
+
* '/api': {
|
|
43
|
+
* accepts: refundable({
|
|
44
|
+
* scheme: 'exact',
|
|
45
|
+
* payTo: '0xmerchant123...',
|
|
46
|
+
* price: '$0.01',
|
|
47
|
+
* network: 'eip155:84532',
|
|
48
|
+
* }),
|
|
49
|
+
* },
|
|
50
|
+
* };
|
|
51
|
+
*
|
|
52
|
+
* // Process routes to route refundable payments to DepositRelay
|
|
53
|
+
* // Uses CREATE3 - no bytecode needed! Works with any CREATE3-compatible factory.
|
|
54
|
+
* // Version is optional - defaults to config file value
|
|
55
|
+
* // CreateX address is optional - uses standard address for network if not provided
|
|
56
|
+
* const processedRoutes = withRefund(routes, FACTORY_ADDRESS);
|
|
57
|
+
*
|
|
58
|
+
* // Use processedRoutes with paymentMiddleware
|
|
59
|
+
* app.use(paymentMiddleware(processedRoutes, server));
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* ## For Facilitators
|
|
63
|
+
*
|
|
64
|
+
* Facilitators use `settleWithRefundHelper()` in hooks to handle refund settlements:
|
|
65
|
+
*
|
|
66
|
+
* ```typescript
|
|
67
|
+
* import { settleWithRefundHelper } from '@x402r/extensions/refund';
|
|
68
|
+
* import { x402Facilitator } from '@x402/core/facilitator';
|
|
69
|
+
*
|
|
70
|
+
* const ESCROW_FACTORY = '0xEscrowFactory123...';
|
|
71
|
+
*
|
|
72
|
+
* facilitator.onBeforeSettle(async (context) => {
|
|
73
|
+
* const result = await settleWithRefundHelper(
|
|
74
|
+
* context.paymentPayload,
|
|
75
|
+
* context.paymentRequirements,
|
|
76
|
+
* signer,
|
|
77
|
+
* ESCROW_FACTORY,
|
|
78
|
+
* );
|
|
79
|
+
*
|
|
80
|
+
* if (result) {
|
|
81
|
+
* // Refund was handled via DepositRelay
|
|
82
|
+
* return { abort: true, reason: 'handled_by_refund_helper' };
|
|
83
|
+
* }
|
|
84
|
+
*
|
|
85
|
+
* return null; // Proceed with normal settlement
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* ## How It Works
|
|
90
|
+
*
|
|
91
|
+
* 1. **Merchant Setup**: Merchant deploys escrow via EscrowFactory and marks options with `refundable()`
|
|
92
|
+
* 2. **Route Processing**: `withRefund()` sets `payTo` to DepositRelay address, stores original merchantPayout in `extra`
|
|
93
|
+
* 3. **Client Payment**: Client makes payment to DepositRelay address (transparent to client)
|
|
94
|
+
* 4. **Facilitator Settlement**: Facilitator detects refund payment, queries EscrowFactory for escrow, calls DepositRelay.executeDeposit()
|
|
95
|
+
* 5. **Escrow Hold**: Funds are held in escrow, enabling dispute resolution and refunds
|
|
96
|
+
*
|
|
97
|
+
* ## Key Features
|
|
98
|
+
*
|
|
99
|
+
* - **No Core Changes**: Works entirely through helpers and hooks
|
|
100
|
+
* - **Client Transparent**: Clients don't need to change anything
|
|
101
|
+
* - **Flexible**: Mix refundable and non-refundable options in same route
|
|
102
|
+
* - **Deep Cloning**: All helpers return new objects, don't mutate originals
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
// Export types
|
|
106
|
+
export {
|
|
107
|
+
REFUND_EXTENSION_KEY,
|
|
108
|
+
REFUND_MARKER_KEY,
|
|
109
|
+
isRefundableOption,
|
|
110
|
+
type RefundExtension,
|
|
111
|
+
type RefundExtensionInfo,
|
|
112
|
+
} from "./types";
|
|
113
|
+
|
|
114
|
+
// Export server-side helpers
|
|
115
|
+
export { declareRefundExtension, refundable, withRefund } from "./server";
|
|
116
|
+
export { computeRelayAddress } from "./server/computeRelayAddress";
|
|
117
|
+
|
|
118
|
+
// Export facilitator-side helpers
|
|
119
|
+
export { extractRefundInfo, settleWithRefundHelper } from "./facilitator";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper to compute CREATE3 address for RelayProxy
|
|
3
|
+
*
|
|
4
|
+
* Uses the CREATE3 formula via CreateX (matching Solidity implementation):
|
|
5
|
+
*
|
|
6
|
+
* Where:
|
|
7
|
+
* - salt = keccak256(abi.encodePacked(factoryAddress, merchantPayout))
|
|
8
|
+
* - guardedSalt = keccak256(abi.encode(salt)) // CreateX guards the salt
|
|
9
|
+
* - createxDeployer = the CreateX contract address
|
|
10
|
+
*
|
|
11
|
+
* CREATE3 is much simpler than CREATE2 - no bytecode needed!
|
|
12
|
+
* The address depends only on the deployer (CreateX) and salt.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: The CreateX address must match the one used by the factory contract.
|
|
15
|
+
* The factory stores its CreateX address and can be queried via factory.getCreateX().
|
|
16
|
+
* This function computes addresses locally without any on-chain calls.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { keccak256, encodePacked, encodeAbiParameters, getAddress } from "viem";
|
|
20
|
+
import { predictCreate3Address } from "@whoislewys/predict-deterministic-address";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Computes the CREATE3 address for a merchant's relay proxy
|
|
24
|
+
*
|
|
25
|
+
* This matches the Solidity implementation in DepositRelayFactory.getRelayAddress():
|
|
26
|
+
* 1. salt = keccak256(abi.encodePacked(merchantPayout))
|
|
27
|
+
* 2. guardedSalt = keccak256(abi.encode(salt)) // CreateX guards the salt
|
|
28
|
+
* 3. return CREATEX.computeCreate3Address(guardedSalt)
|
|
29
|
+
*
|
|
30
|
+
* Uses the @whoislewys/predict-deterministic-address library which correctly
|
|
31
|
+
* implements the CREATE3 formula used by CreateX (based on Solady's CREATE3).
|
|
32
|
+
* This ensures the computed address matches the factory's on-chain computation
|
|
33
|
+
* without requiring any on-chain calls.
|
|
34
|
+
*
|
|
35
|
+
* @param createxAddress - The CreateX contract address
|
|
36
|
+
* @param factoryAddress - The DepositRelayFactory contract address
|
|
37
|
+
* @param merchantPayout - The merchant's payout address
|
|
38
|
+
* @returns The deterministic proxy address
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* // No bytecode needed! Computes locally without on-chain calls.
|
|
43
|
+
* const relayAddress = computeRelayAddress(
|
|
44
|
+
* "0xCreateX123...",
|
|
45
|
+
* "0xMerchant123...",
|
|
46
|
+
* 0n // version
|
|
47
|
+
* );
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function computeRelayAddress(
|
|
51
|
+
createxAddress: string,
|
|
52
|
+
factoryAddress: string,
|
|
53
|
+
merchantPayout: string,
|
|
54
|
+
): string {
|
|
55
|
+
// Normalize addresses to checksummed format
|
|
56
|
+
const createx = getAddress(createxAddress);
|
|
57
|
+
const factory = getAddress(factoryAddress);
|
|
58
|
+
const merchant = getAddress(merchantPayout);
|
|
59
|
+
|
|
60
|
+
// Step 1: salt = keccak256(abi.encodePacked(factoryAddress, merchantPayout))
|
|
61
|
+
const salt = keccak256(encodePacked(["address", "address"], [factory, merchant]));
|
|
62
|
+
|
|
63
|
+
// Step 2: guardedSalt = keccak256(abi.encode(salt))
|
|
64
|
+
// Note: abi.encode (not encodePacked) - this adds length prefixes
|
|
65
|
+
const guardedSalt = keccak256(
|
|
66
|
+
encodeAbiParameters([{ type: "bytes32" }], [salt as `0x${string}`]),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Step 3: Use predictCreate3Address to compute the CREATE3 address
|
|
70
|
+
// This matches CreateX's computeCreate3Address implementation exactly
|
|
71
|
+
// No on-chain calls needed - computes locally!
|
|
72
|
+
return predictCreate3Address(createx, guardedSalt as `0x${string}`);
|
|
73
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side helpers for the Refund Helper Extension
|
|
3
|
+
*
|
|
4
|
+
* These helpers allow merchants to mark payment options as refundable
|
|
5
|
+
* and process route configurations to route payments to X402DepositRelayProxy contracts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PaymentOption, RouteConfig, RoutesConfig } from "@x402/core/http";
|
|
9
|
+
import {
|
|
10
|
+
REFUND_MARKER_KEY,
|
|
11
|
+
isRefundableOption,
|
|
12
|
+
REFUND_EXTENSION_KEY,
|
|
13
|
+
type RefundExtension,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import { computeRelayAddress } from "./server/computeRelayAddress";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Declares a refund extension with factory address and merchantPayouts map
|
|
19
|
+
*
|
|
20
|
+
* @param factoryAddress - The X402DepositRelayFactory contract address
|
|
21
|
+
* @param merchantPayouts - Map of proxy address to merchant payout address
|
|
22
|
+
* @returns Refund extension object with info and schema
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const extension = declareRefundExtension("0xFactory123...", {
|
|
27
|
+
* "0xProxy1...": "0xMerchant1...",
|
|
28
|
+
* "0xProxy2...": "0xMerchant2...",
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function declareRefundExtension(
|
|
33
|
+
factoryAddress: string,
|
|
34
|
+
merchantPayouts: Record<string, string>,
|
|
35
|
+
): Record<string, RefundExtension> {
|
|
36
|
+
return {
|
|
37
|
+
[REFUND_EXTENSION_KEY]: {
|
|
38
|
+
info: {
|
|
39
|
+
factoryAddress,
|
|
40
|
+
merchantPayouts,
|
|
41
|
+
},
|
|
42
|
+
schema: {
|
|
43
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
factoryAddress: {
|
|
47
|
+
type: "string",
|
|
48
|
+
pattern: "^0x[a-fA-F0-9]{40}$",
|
|
49
|
+
description: "The X402DepositRelayFactory contract address",
|
|
50
|
+
},
|
|
51
|
+
merchantPayouts: {
|
|
52
|
+
type: "object",
|
|
53
|
+
additionalProperties: {
|
|
54
|
+
type: "string",
|
|
55
|
+
pattern: "^0x[a-fA-F0-9]{40}$",
|
|
56
|
+
},
|
|
57
|
+
description: "Map of proxy address to merchant payout address",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["factoryAddress", "merchantPayouts"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Marks a payment option as refundable.
|
|
69
|
+
*
|
|
70
|
+
* This function marks the option as refundable so it can be processed by `withRefund()`.
|
|
71
|
+
* The merchantPayout is read directly from the option's `payTo` field when processing.
|
|
72
|
+
*
|
|
73
|
+
* @param option - The payment option to mark as refundable
|
|
74
|
+
* @returns A new PaymentOption marked as refundable (does not mutate original)
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const refundableOption = refundable({
|
|
79
|
+
* scheme: "exact",
|
|
80
|
+
* payTo: "0xmerchant123...",
|
|
81
|
+
* price: "$0.01",
|
|
82
|
+
* network: "eip155:84532",
|
|
83
|
+
* });
|
|
84
|
+
* // refundableOption.extra._x402_refund = true
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function refundable(option: PaymentOption): PaymentOption {
|
|
88
|
+
// Deep clone the option to avoid mutation
|
|
89
|
+
const clonedOption: PaymentOption = {
|
|
90
|
+
...option,
|
|
91
|
+
extra: {
|
|
92
|
+
...option.extra,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Set marker to indicate this option is refundable
|
|
97
|
+
if (!clonedOption.extra) {
|
|
98
|
+
clonedOption.extra = {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
clonedOption.extra[REFUND_MARKER_KEY] = true;
|
|
102
|
+
|
|
103
|
+
return clonedOption;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Standard CreateX contract addresses per network.
|
|
108
|
+
* These are the official CreateX deployments from https://github.com/pcaversaccio/createx#createx-deployments
|
|
109
|
+
*
|
|
110
|
+
* Note: If a network is not listed here, CreateX may need to be deployed separately.
|
|
111
|
+
* The factory stores the CreateX address and can be queried via factory.getCreateX().
|
|
112
|
+
*/
|
|
113
|
+
const STANDARD_CREATEX_ADDRESSES: Record<string, string> = {
|
|
114
|
+
// Ethereum Mainnet
|
|
115
|
+
"eip155:1": "0xba5Ed099633D3B313e4D5F7bdc1305d3c32ba066",
|
|
116
|
+
// Base Mainnet
|
|
117
|
+
"eip155:8453": "0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed",
|
|
118
|
+
// Base Sepolia
|
|
119
|
+
"eip155:84532": "0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed",
|
|
120
|
+
// Add more networks as CreateX deployments become available
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Processes route configuration to handle refundable payment options.
|
|
125
|
+
*
|
|
126
|
+
* This function finds all payment options marked with `refundable()` and:
|
|
127
|
+
* 1. Computes the proxy address using CREATE3 (no bytecode needed!)
|
|
128
|
+
* 2. Sets `payTo` to the proxy address
|
|
129
|
+
* 3. Adds the refund extension with factory address
|
|
130
|
+
*
|
|
131
|
+
* @param routes - Route configuration (single RouteConfig or Record<string, RouteConfig>)
|
|
132
|
+
* @param factoryAddress - The X402DepositRelayFactory contract address (required)
|
|
133
|
+
* @param createxAddress - The CreateX contract address (optional, will use standard address for network if not provided)
|
|
134
|
+
* @returns A new RoutesConfig with refundable options routed to proxy (deep cloned, does not mutate original)
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const routes = {
|
|
139
|
+
* "/api": {
|
|
140
|
+
* accepts: refundable({
|
|
141
|
+
* scheme: "exact",
|
|
142
|
+
* payTo: "0xmerchant123...",
|
|
143
|
+
* price: "$0.01",
|
|
144
|
+
* network: "eip155:84532",
|
|
145
|
+
* }),
|
|
146
|
+
* },
|
|
147
|
+
* };
|
|
148
|
+
*
|
|
149
|
+
* // Version is optional - defaults to config file value
|
|
150
|
+
* // CreateX address is optional - uses standard address for the network
|
|
151
|
+
* const processedRoutes = withRefund(routes, "0xFactory123...");
|
|
152
|
+
* // processedRoutes["/api"].accepts.payTo = computed proxy address
|
|
153
|
+
* // processedRoutes["/api"].extensions.refund = { info: { factoryAddress: "0xFactory123..." }, schema: {...} }
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function withRefund(
|
|
157
|
+
routes: RoutesConfig,
|
|
158
|
+
factoryAddress: string,
|
|
159
|
+
createxAddress?: string,
|
|
160
|
+
): RoutesConfig {
|
|
161
|
+
// Deep clone to avoid mutation
|
|
162
|
+
if (typeof routes === "object" && routes !== null && !("accepts" in routes)) {
|
|
163
|
+
// Nested RoutesConfig: Record<string, RouteConfig>
|
|
164
|
+
const nestedRoutes = routes as Record<string, RouteConfig>;
|
|
165
|
+
const processedRoutes: Record<string, RouteConfig> = {};
|
|
166
|
+
|
|
167
|
+
for (const [pattern, config] of Object.entries(nestedRoutes)) {
|
|
168
|
+
processedRoutes[pattern] = processRouteConfig(config, factoryAddress, createxAddress);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return processedRoutes;
|
|
172
|
+
} else {
|
|
173
|
+
// Single RouteConfig
|
|
174
|
+
return processRouteConfig(routes as RouteConfig, factoryAddress, createxAddress);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets the CreateX address for a given network.
|
|
180
|
+
* First checks if provided explicitly, then falls back to standard addresses.
|
|
181
|
+
*
|
|
182
|
+
* @param network - The network identifier (e.g., "eip155:84532")
|
|
183
|
+
* @param providedAddress - Optional CreateX address provided by user
|
|
184
|
+
* @returns The CreateX address to use
|
|
185
|
+
* @throws Error if no CreateX address can be determined
|
|
186
|
+
*/
|
|
187
|
+
function getCreateXAddress(network: string, providedAddress?: string): string {
|
|
188
|
+
if (providedAddress) {
|
|
189
|
+
return providedAddress;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const standardAddress = STANDARD_CREATEX_ADDRESSES[network];
|
|
193
|
+
if (standardAddress) {
|
|
194
|
+
return standardAddress;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(
|
|
198
|
+
`CreateX address not provided and no standard address found for network ${network}. ` +
|
|
199
|
+
`Please provide createxAddress parameter or check if CreateX is deployed on this network. ` +
|
|
200
|
+
`See https://github.com/pcaversaccio/createx#createx-deployments for standard deployments.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Processes a single RouteConfig to transform refundable payment options.
|
|
206
|
+
*
|
|
207
|
+
* @param config - The route configuration to process
|
|
208
|
+
* @param factoryAddress - The X402DepositRelayFactory contract address
|
|
209
|
+
* @param createxAddress - The CreateX contract address (optional)
|
|
210
|
+
* @returns A new RouteConfig with refundable options transformed
|
|
211
|
+
*/
|
|
212
|
+
function processRouteConfig(
|
|
213
|
+
config: RouteConfig,
|
|
214
|
+
factoryAddress: string,
|
|
215
|
+
createxAddress?: string,
|
|
216
|
+
): RouteConfig {
|
|
217
|
+
// Get the network from the first payment option to determine CreateX address
|
|
218
|
+
const firstOption = Array.isArray(config.accepts) ? config.accepts[0] : config.accepts;
|
|
219
|
+
const network = firstOption?.network;
|
|
220
|
+
|
|
221
|
+
if (!network) {
|
|
222
|
+
throw new Error("Payment option must have a network field to determine CreateX address");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Get CreateX address (from parameter or standard mapping)
|
|
226
|
+
const resolvedCreatexAddress = getCreateXAddress(network, createxAddress);
|
|
227
|
+
|
|
228
|
+
// Check if any option is refundable
|
|
229
|
+
const hasRefundable = Array.isArray(config.accepts)
|
|
230
|
+
? config.accepts.some(isRefundableOption)
|
|
231
|
+
: isRefundableOption(config.accepts);
|
|
232
|
+
|
|
233
|
+
// Build map of proxyAddress -> merchantPayout BEFORE processing options
|
|
234
|
+
// This allows us to store all merchantPayouts even if there are multiple refundable options
|
|
235
|
+
const merchantPayoutsMap: Record<string, string> = {};
|
|
236
|
+
|
|
237
|
+
if (hasRefundable) {
|
|
238
|
+
const refundableOptions = Array.isArray(config.accepts)
|
|
239
|
+
? config.accepts.filter(isRefundableOption)
|
|
240
|
+
: isRefundableOption(config.accepts)
|
|
241
|
+
? [config.accepts]
|
|
242
|
+
: [];
|
|
243
|
+
|
|
244
|
+
// Collect merchantPayouts from original options before processing
|
|
245
|
+
for (const option of refundableOptions) {
|
|
246
|
+
if (typeof option.payTo === "string") {
|
|
247
|
+
const merchantPayout = option.payTo; // Original merchantPayout (before we overwrite it)
|
|
248
|
+
const proxyAddress = computeRelayAddress(
|
|
249
|
+
resolvedCreatexAddress,
|
|
250
|
+
factoryAddress,
|
|
251
|
+
merchantPayout,
|
|
252
|
+
);
|
|
253
|
+
merchantPayoutsMap[proxyAddress.toLowerCase()] = merchantPayout;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Deep clone the config and process options
|
|
259
|
+
const processedConfig: RouteConfig = {
|
|
260
|
+
...config,
|
|
261
|
+
accepts: Array.isArray(config.accepts)
|
|
262
|
+
? config.accepts.map(option =>
|
|
263
|
+
processPaymentOption(option, factoryAddress, resolvedCreatexAddress),
|
|
264
|
+
)
|
|
265
|
+
: processPaymentOption(config.accepts, factoryAddress, resolvedCreatexAddress),
|
|
266
|
+
extensions: {
|
|
267
|
+
...config.extensions,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Add refund extension if any option is refundable
|
|
272
|
+
if (hasRefundable && Object.keys(merchantPayoutsMap).length > 0) {
|
|
273
|
+
processedConfig.extensions = {
|
|
274
|
+
...processedConfig.extensions,
|
|
275
|
+
...declareRefundExtension(factoryAddress, merchantPayoutsMap),
|
|
276
|
+
};
|
|
277
|
+
} else if (hasRefundable) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"Refundable option must have a string payTo address. DynamicPayTo is not supported for refundable options.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return processedConfig;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Processes a single PaymentOption to transform it if refundable.
|
|
288
|
+
*
|
|
289
|
+
* @param option - The payment option to process
|
|
290
|
+
* @param factoryAddress - The X402DepositRelayFactory contract address
|
|
291
|
+
* @param createxAddress - The CreateX contract address (required)
|
|
292
|
+
* @returns A new PaymentOption (transformed if refundable, unchanged otherwise)
|
|
293
|
+
*/
|
|
294
|
+
function processPaymentOption(
|
|
295
|
+
option: PaymentOption,
|
|
296
|
+
factoryAddress: string,
|
|
297
|
+
createxAddress: string,
|
|
298
|
+
): PaymentOption {
|
|
299
|
+
// Check if option is refundable
|
|
300
|
+
if (isRefundableOption(option)) {
|
|
301
|
+
// Read merchantPayout directly from payTo field (before we overwrite it)
|
|
302
|
+
const merchantPayout = option.payTo;
|
|
303
|
+
|
|
304
|
+
// If it's a function (DynamicPayTo), we can't compute the address (would need to call it)
|
|
305
|
+
// For now, require it to be a string
|
|
306
|
+
if (typeof merchantPayout !== "string") {
|
|
307
|
+
throw new Error(
|
|
308
|
+
"DynamicPayTo is not supported for refundable options. Use a static address.",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Compute proxy address using CREATE3 (no bytecode needed!)
|
|
313
|
+
const proxyAddress = computeRelayAddress(createxAddress, factoryAddress, merchantPayout);
|
|
314
|
+
|
|
315
|
+
// Deep clone the option
|
|
316
|
+
const processedOption: PaymentOption = {
|
|
317
|
+
...option,
|
|
318
|
+
payTo: proxyAddress, // Set payTo to proxy address
|
|
319
|
+
extra: {
|
|
320
|
+
...option.extra,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Remove the marker since we've processed it
|
|
325
|
+
if (processedOption.extra) {
|
|
326
|
+
delete processedOption.extra[REFUND_MARKER_KEY];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return processedOption;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Not refundable, return as-is (still clone to avoid mutation)
|
|
333
|
+
return { ...option, extra: { ...option.extra } };
|
|
334
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the Refund Helper Extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PaymentOption } from "@x402/core/http";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extension identifier constant for the refund extension
|
|
9
|
+
*/
|
|
10
|
+
export const REFUND_EXTENSION_KEY = "refund";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Constant for the refund marker key (internal marker)
|
|
14
|
+
* Used to identify refundable payment options
|
|
15
|
+
* The merchantPayout is read directly from the option's payTo field when processing
|
|
16
|
+
*/
|
|
17
|
+
export const REFUND_MARKER_KEY = "_x402_refund";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Refund extension info structure
|
|
21
|
+
*
|
|
22
|
+
* merchantPayouts: Map of proxy address -> merchantPayout
|
|
23
|
+
* This allows multiple refundable options with different merchantPayouts
|
|
24
|
+
*/
|
|
25
|
+
export interface RefundExtensionInfo {
|
|
26
|
+
factoryAddress: string;
|
|
27
|
+
merchantPayouts: Record<string, string>; // proxyAddress -> merchantPayout
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Refund extension structure (matches extension pattern with info and schema)
|
|
32
|
+
*/
|
|
33
|
+
export interface RefundExtension {
|
|
34
|
+
info: RefundExtensionInfo;
|
|
35
|
+
schema: {
|
|
36
|
+
$schema: "https://json-schema.org/draft/2020-12/schema";
|
|
37
|
+
type: "object";
|
|
38
|
+
properties: {
|
|
39
|
+
factoryAddress: {
|
|
40
|
+
type: "string";
|
|
41
|
+
pattern: "^0x[a-fA-F0-9]{40}$";
|
|
42
|
+
description: "The X402DepositRelayFactory contract address";
|
|
43
|
+
};
|
|
44
|
+
merchantPayouts: {
|
|
45
|
+
type: "object";
|
|
46
|
+
additionalProperties: {
|
|
47
|
+
type: "string";
|
|
48
|
+
pattern: "^0x[a-fA-F0-9]{40}$";
|
|
49
|
+
};
|
|
50
|
+
description: "Map of proxy address to merchant payout address";
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
required: ["factoryAddress", "merchantPayouts"];
|
|
54
|
+
additionalProperties: false;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Type guard to check if a payment option is refundable
|
|
60
|
+
* A refundable option has a marker stored in extra
|
|
61
|
+
*
|
|
62
|
+
* @param option - The payment option to check
|
|
63
|
+
* @returns True if the option is refundable
|
|
64
|
+
*/
|
|
65
|
+
export function isRefundableOption(option: PaymentOption): boolean {
|
|
66
|
+
// Check for marker in extra
|
|
67
|
+
return (
|
|
68
|
+
option.extra !== undefined &&
|
|
69
|
+
typeof option.extra === "object" &&
|
|
70
|
+
option.extra !== null &&
|
|
71
|
+
REFUND_MARKER_KEY in option.extra &&
|
|
72
|
+
option.extra[REFUND_MARKER_KEY] === true
|
|
73
|
+
);
|
|
74
|
+
}
|