@4mica/x402 0.1.0 → 1.0.1
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/demo/README.md +3 -3
- package/demo/package.json +1 -1
- package/demo/src/deposit.ts +0 -2
- package/dist/client/scheme.js +13 -1
- package/dist/server/express/index.js +22 -11
- package/dist/server/facilitator.d.ts +12 -1
- package/dist/server/facilitator.js +74 -0
- package/package.json +1 -1
- package/src/client/scheme.ts +16 -1
- package/src/server/express/index.ts +41 -9
- package/src/server/facilitator.ts +115 -1
- package/tests/client-scheme.test.ts +97 -0
- package/tests/facilitator.test.ts +174 -0
package/demo/README.md
CHANGED
|
@@ -32,7 +32,7 @@ cp .env.example .env
|
|
|
32
32
|
|
|
33
33
|
Required variables:
|
|
34
34
|
- `PRIVATE_KEY`: Your Ethereum private key (with 0x prefix) for Sepolia testnet
|
|
35
|
-
- `PAY_TO_ADDRESS`: Address that will receive payments
|
|
35
|
+
- `PAY_TO_ADDRESS`: Address that will receive payments
|
|
36
36
|
|
|
37
37
|
## Running the Demo
|
|
38
38
|
|
|
@@ -54,7 +54,7 @@ You should see:
|
|
|
54
54
|
```
|
|
55
55
|
x402 Demo Server running on http://localhost:3000
|
|
56
56
|
Protected endpoint: http://localhost:3000/api/premium-data
|
|
57
|
-
Payment required: $0.
|
|
57
|
+
Payment required: $0.01 (4mica credit on Sepolia)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
### Terminal 2: Run the client
|
|
@@ -82,7 +82,7 @@ PRIVATE_KEY=0xYourPrivateKey yarn client
|
|
|
82
82
|
## What Happens
|
|
83
83
|
|
|
84
84
|
1. **Server** starts with one protected endpoint: `GET /api/premium-data`
|
|
85
|
-
- Requires a payment of $0.
|
|
85
|
+
- Requires a payment of $0.01 in 4mica credits on Sepolia
|
|
86
86
|
- Uses x402 payment protocol
|
|
87
87
|
|
|
88
88
|
2. **Client** makes a request to the protected endpoint:
|
package/demo/package.json
CHANGED
package/demo/src/deposit.ts
CHANGED
|
@@ -2,8 +2,6 @@ import 'dotenv/config'
|
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
3
|
import { Client, ConfigBuilder } from '@4mica/sdk'
|
|
4
4
|
|
|
5
|
-
const USDC_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'
|
|
6
|
-
|
|
7
5
|
async function main() {
|
|
8
6
|
const privateKey = process.env.PRIVATE_KEY
|
|
9
7
|
if (!privateKey || !privateKey.startsWith('0x')) {
|
package/dist/client/scheme.js
CHANGED
|
@@ -49,9 +49,21 @@ export class FourMicaEvmScheme {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
else if (x402Version === 2) {
|
|
52
|
+
const resourcePayload = paymentRequirements.extra &&
|
|
53
|
+
typeof paymentRequirements.extra === 'object' &&
|
|
54
|
+
'resource' in paymentRequirements.extra &&
|
|
55
|
+
typeof paymentRequirements.extra.resource === 'object' &&
|
|
56
|
+
paymentRequirements.extra.resource !== null
|
|
57
|
+
? paymentRequirements.extra.resource
|
|
58
|
+
: {};
|
|
59
|
+
const resource = {
|
|
60
|
+
url: String(resourcePayload.url ?? ''),
|
|
61
|
+
description: String(resourcePayload.description ?? ''),
|
|
62
|
+
mimeType: String(resourcePayload.mimeType ?? ''),
|
|
63
|
+
};
|
|
52
64
|
const paymentRequired = {
|
|
53
65
|
x402Version: 2,
|
|
54
|
-
resource
|
|
66
|
+
resource,
|
|
55
67
|
accepts: [paymentRequirements],
|
|
56
68
|
};
|
|
57
69
|
const signed = await x402Flow.signPaymentV2(paymentRequired, paymentRequirements, this.signer.address);
|
|
@@ -2,11 +2,14 @@ import { x402HTTPResourceServer, x402ResourceServer, } from '@x402/core/server';
|
|
|
2
2
|
import { ExpressAdapter } from './adapter.js';
|
|
3
3
|
import { FourMicaEvmScheme, SUPPORTED_NETWORKS } from '../scheme.js';
|
|
4
4
|
import { FourMicaFacilitatorClient } from '../facilitator.js';
|
|
5
|
+
function getHTTPServerInternals(httpServer) {
|
|
6
|
+
return httpServer;
|
|
7
|
+
}
|
|
5
8
|
function registerNetworkServers(httpServer, tabEndpoint) {
|
|
6
9
|
const schemeServer = new FourMicaEvmScheme(tabEndpoint);
|
|
10
|
+
const server = getHTTPServerInternals(httpServer);
|
|
7
11
|
SUPPORTED_NETWORKS.forEach((network) => {
|
|
8
|
-
;
|
|
9
|
-
httpServer.ResourceServer.register(network, schemeServer);
|
|
12
|
+
server.ResourceServer.register(network, schemeServer);
|
|
10
13
|
});
|
|
11
14
|
}
|
|
12
15
|
function checkIfBazaarNeeded(routes) {
|
|
@@ -17,6 +20,13 @@ function checkIfBazaarNeeded(routes) {
|
|
|
17
20
|
return !!(routeConfig.extensions && 'bazaar' in routeConfig.extensions);
|
|
18
21
|
});
|
|
19
22
|
}
|
|
23
|
+
function isOpenTabHttpError(error) {
|
|
24
|
+
if (typeof error !== 'object' || error === null) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const candidate = error;
|
|
28
|
+
return typeof candidate.status === 'number' && 'response' in candidate;
|
|
29
|
+
}
|
|
20
30
|
/**
|
|
21
31
|
* Express payment middleware for x402 protocol (direct HTTP server instance).
|
|
22
32
|
*
|
|
@@ -57,12 +67,12 @@ export function paymentMiddlewareFromHTTPServer(httpServer, tabConfig, paywallCo
|
|
|
57
67
|
// Dynamically register bazaar extension if routes declare it and not already registered
|
|
58
68
|
// Skip if pre-registered (e.g., in serverless environments where static imports are used)
|
|
59
69
|
let bazaarPromise = null;
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const httpServerInternals = getHTTPServerInternals(httpServer);
|
|
71
|
+
if (checkIfBazaarNeeded(httpServerInternals.routesConfig) &&
|
|
72
|
+
!httpServerInternals.ResourceServer.hasExtension('bazaar')) {
|
|
62
73
|
bazaarPromise = import('@x402/extensions/bazaar')
|
|
63
74
|
.then(({ bazaarResourceServerExtension }) => {
|
|
64
|
-
;
|
|
65
|
-
httpServer.ResourceServer.registerExtension(bazaarResourceServerExtension);
|
|
75
|
+
httpServerInternals.ResourceServer.registerExtension(bazaarResourceServerExtension);
|
|
66
76
|
})
|
|
67
77
|
.catch((err) => {
|
|
68
78
|
console.error('Failed to load bazaar extension:', err);
|
|
@@ -82,9 +92,8 @@ export function paymentMiddlewareFromHTTPServer(httpServer, tabConfig, paywallCo
|
|
|
82
92
|
return res.json(openTabResponse);
|
|
83
93
|
}
|
|
84
94
|
catch (error) {
|
|
85
|
-
if (error
|
|
86
|
-
|
|
87
|
-
return res.status(openTabError.status).json(openTabError.response);
|
|
95
|
+
if (isOpenTabHttpError(error)) {
|
|
96
|
+
return res.status(error.status).json(error.response);
|
|
88
97
|
}
|
|
89
98
|
console.error('Failed to open tab:', error);
|
|
90
99
|
return res.status(500).json({
|
|
@@ -126,7 +135,7 @@ export function paymentMiddlewareFromHTTPServer(httpServer, tabConfig, paywallCo
|
|
|
126
135
|
case 'no-payment-required':
|
|
127
136
|
// No payment needed, proceed directly to the route handler
|
|
128
137
|
return next();
|
|
129
|
-
case 'payment-error':
|
|
138
|
+
case 'payment-error': {
|
|
130
139
|
// Payment required but not provided or invalid
|
|
131
140
|
const { response } = result;
|
|
132
141
|
res.status(response.status);
|
|
@@ -140,7 +149,8 @@ export function paymentMiddlewareFromHTTPServer(httpServer, tabConfig, paywallCo
|
|
|
140
149
|
res.json(response.body || {});
|
|
141
150
|
}
|
|
142
151
|
return;
|
|
143
|
-
|
|
152
|
+
}
|
|
153
|
+
case 'payment-verified': {
|
|
144
154
|
// Payment is valid, need to wrap response for settlement
|
|
145
155
|
const { paymentPayload, paymentRequirements } = result;
|
|
146
156
|
// Intercept and buffer all core methods that can commit response to client
|
|
@@ -256,6 +266,7 @@ export function paymentMiddlewareFromHTTPServer(httpServer, tabConfig, paywallCo
|
|
|
256
266
|
bufferedCalls = [];
|
|
257
267
|
}
|
|
258
268
|
return;
|
|
269
|
+
}
|
|
259
270
|
}
|
|
260
271
|
};
|
|
261
272
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FacilitatorConfig, HTTPFacilitatorClient } from '@x402/core/server';
|
|
2
|
-
import { Network, PaymentRequirements } from '@x402/core/types';
|
|
2
|
+
import { Network, PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
|
|
3
3
|
export interface OpenTabRequest {
|
|
4
4
|
userAddress: string;
|
|
5
5
|
recipientAddress: string;
|
|
@@ -16,6 +16,16 @@ export interface OpenTabResponse {
|
|
|
16
16
|
ttlSeconds: number;
|
|
17
17
|
nextReqId: string;
|
|
18
18
|
}
|
|
19
|
+
export interface CertificateResponse {
|
|
20
|
+
claims: string;
|
|
21
|
+
signature: string;
|
|
22
|
+
}
|
|
23
|
+
export type FourMicaSettleResponse = SettleResponse & {
|
|
24
|
+
certificate?: CertificateResponse;
|
|
25
|
+
txHash?: string;
|
|
26
|
+
networkId?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
};
|
|
19
29
|
export declare class OpenTabError extends Error {
|
|
20
30
|
readonly status: number;
|
|
21
31
|
readonly response: OpenTabResponse;
|
|
@@ -24,6 +34,7 @@ export declare class OpenTabError extends Error {
|
|
|
24
34
|
export declare class FourMicaFacilitatorClient extends HTTPFacilitatorClient {
|
|
25
35
|
constructor(config?: FacilitatorConfig);
|
|
26
36
|
openTab(userAddress: string, paymentRequirements: PaymentRequirements, ttlSeconds?: number): Promise<OpenTabResponse>;
|
|
37
|
+
settle(paymentPayload: PaymentPayload, paymentRequirements: PaymentRequirements): Promise<FourMicaSettleResponse>;
|
|
27
38
|
/**
|
|
28
39
|
* Helper to convert objects to JSON-safe format.
|
|
29
40
|
* Handles BigInt and other non-JSON types.
|
|
@@ -39,6 +39,28 @@ export class FourMicaFacilitatorClient extends HTTPFacilitatorClient {
|
|
|
39
39
|
}
|
|
40
40
|
throw new Error(`Facilitator openTab failed (${response.status}): ${JSON.stringify(data)}`);
|
|
41
41
|
}
|
|
42
|
+
async settle(paymentPayload, paymentRequirements) {
|
|
43
|
+
let headers = {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
};
|
|
46
|
+
const authHeaders = await this.createAuthHeaders('settle');
|
|
47
|
+
headers = { ...headers, ...authHeaders.headers };
|
|
48
|
+
const response = await fetch(`${this.url}/settle`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify(this.safeJson({
|
|
52
|
+
x402Version: paymentPayload.x402Version,
|
|
53
|
+
paymentPayload,
|
|
54
|
+
paymentRequirements,
|
|
55
|
+
})),
|
|
56
|
+
});
|
|
57
|
+
const data = (await response.json());
|
|
58
|
+
const normalized = normalizeSettleResponse(data, paymentRequirements);
|
|
59
|
+
if (!response.ok || !normalized.success) {
|
|
60
|
+
throw new Error(`Facilitator settle failed (${response.status}): ${normalized.errorReason ?? normalized.error ?? 'unknown error'}`);
|
|
61
|
+
}
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
42
64
|
/**
|
|
43
65
|
* Helper to convert objects to JSON-safe format.
|
|
44
66
|
* Handles BigInt and other non-JSON types.
|
|
@@ -50,3 +72,55 @@ export class FourMicaFacilitatorClient extends HTTPFacilitatorClient {
|
|
|
50
72
|
return JSON.parse(JSON.stringify(obj, (_, value) => (typeof value === 'bigint' ? value.toString() : value)));
|
|
51
73
|
}
|
|
52
74
|
}
|
|
75
|
+
function normalizeSettleResponse(payload, requirements) {
|
|
76
|
+
const transaction = String(payload.transaction ??
|
|
77
|
+
payload.transactionHash ??
|
|
78
|
+
payload.txHash ??
|
|
79
|
+
payload.tx_hash ??
|
|
80
|
+
payload.hash ??
|
|
81
|
+
'');
|
|
82
|
+
const network = String(payload.network ?? payload.networkId ?? payload.network_id ?? requirements.network);
|
|
83
|
+
const errorReason = typeof payload.errorReason === 'string'
|
|
84
|
+
? payload.errorReason
|
|
85
|
+
: typeof payload.error_reason === 'string'
|
|
86
|
+
? payload.error_reason
|
|
87
|
+
: typeof payload.error === 'string'
|
|
88
|
+
? payload.error
|
|
89
|
+
: typeof payload.message === 'string'
|
|
90
|
+
? payload.message
|
|
91
|
+
: undefined;
|
|
92
|
+
const certificate = payload.certificate &&
|
|
93
|
+
typeof payload.certificate === 'object' &&
|
|
94
|
+
typeof payload.certificate.claims === 'string' &&
|
|
95
|
+
typeof payload.certificate.signature === 'string'
|
|
96
|
+
? {
|
|
97
|
+
claims: payload.certificate.claims,
|
|
98
|
+
signature: payload.certificate.signature,
|
|
99
|
+
}
|
|
100
|
+
: undefined;
|
|
101
|
+
return {
|
|
102
|
+
success: Boolean(payload.success ?? errorReason === undefined),
|
|
103
|
+
errorReason,
|
|
104
|
+
payer: typeof payload.payer === 'string'
|
|
105
|
+
? payload.payer
|
|
106
|
+
: typeof payload.userAddress === 'string'
|
|
107
|
+
? payload.userAddress
|
|
108
|
+
: typeof payload.user_address === 'string'
|
|
109
|
+
? payload.user_address
|
|
110
|
+
: undefined,
|
|
111
|
+
transaction,
|
|
112
|
+
network,
|
|
113
|
+
certificate,
|
|
114
|
+
txHash: typeof payload.txHash === 'string'
|
|
115
|
+
? payload.txHash
|
|
116
|
+
: typeof payload.tx_hash === 'string'
|
|
117
|
+
? payload.tx_hash
|
|
118
|
+
: transaction || undefined,
|
|
119
|
+
networkId: typeof payload.networkId === 'string'
|
|
120
|
+
? payload.networkId
|
|
121
|
+
: typeof payload.network_id === 'string'
|
|
122
|
+
? payload.network_id
|
|
123
|
+
: network,
|
|
124
|
+
error: typeof payload.error === 'string' ? payload.error : undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
package/package.json
CHANGED
package/src/client/scheme.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
PaymentRequirementsV1,
|
|
6
6
|
X402Flow,
|
|
7
7
|
X402PaymentRequired,
|
|
8
|
+
X402ResourceInfo,
|
|
8
9
|
} from '@4mica/sdk'
|
|
9
10
|
import { Account } from 'viem/accounts'
|
|
10
11
|
import { SUPPORTED_NETWORKS } from '../server/scheme.js'
|
|
@@ -73,9 +74,23 @@ export class FourMicaEvmScheme implements SchemeNetworkClient {
|
|
|
73
74
|
payload: signed.payload as unknown as Record<string, unknown>,
|
|
74
75
|
}
|
|
75
76
|
} else if (x402Version === 2) {
|
|
77
|
+
const resourcePayload =
|
|
78
|
+
paymentRequirements.extra &&
|
|
79
|
+
typeof paymentRequirements.extra === 'object' &&
|
|
80
|
+
'resource' in paymentRequirements.extra &&
|
|
81
|
+
typeof paymentRequirements.extra.resource === 'object' &&
|
|
82
|
+
paymentRequirements.extra.resource !== null
|
|
83
|
+
? (paymentRequirements.extra.resource as Record<string, unknown>)
|
|
84
|
+
: {}
|
|
85
|
+
|
|
86
|
+
const resource: X402ResourceInfo = {
|
|
87
|
+
url: String(resourcePayload.url ?? ''),
|
|
88
|
+
description: String(resourcePayload.description ?? ''),
|
|
89
|
+
mimeType: String(resourcePayload.mimeType ?? ''),
|
|
90
|
+
}
|
|
76
91
|
const paymentRequired: X402PaymentRequired = {
|
|
77
92
|
x402Version: 2,
|
|
78
|
-
resource
|
|
93
|
+
resource,
|
|
79
94
|
accepts: [paymentRequirements],
|
|
80
95
|
}
|
|
81
96
|
const signed = await x402Flow.signPaymentV2(
|
|
@@ -36,10 +36,26 @@ interface TabConfig {
|
|
|
36
36
|
ttlSeconds?: number
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface ResourceServerInternals {
|
|
40
|
+
register: (network: Network, server: SchemeNetworkServer) => unknown
|
|
41
|
+
hasExtension: (extension: string) => boolean
|
|
42
|
+
registerExtension: (extension: unknown) => unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface HTTPServerInternals {
|
|
46
|
+
ResourceServer: ResourceServerInternals
|
|
47
|
+
routesConfig: RoutesConfig
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getHTTPServerInternals(httpServer: x402HTTPResourceServer): HTTPServerInternals {
|
|
51
|
+
return httpServer as unknown as HTTPServerInternals
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
function registerNetworkServers(httpServer: x402HTTPResourceServer, tabEndpoint: string) {
|
|
40
55
|
const schemeServer = new FourMicaEvmScheme(tabEndpoint)
|
|
56
|
+
const server = getHTTPServerInternals(httpServer)
|
|
41
57
|
SUPPORTED_NETWORKS.forEach((network) => {
|
|
42
|
-
|
|
58
|
+
server.ResourceServer.register(network, schemeServer)
|
|
43
59
|
})
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -53,6 +69,20 @@ function checkIfBazaarNeeded(routes: RoutesConfig): boolean {
|
|
|
53
69
|
})
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
interface OpenTabHttpError {
|
|
73
|
+
status: number
|
|
74
|
+
response: unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isOpenTabHttpError(error: unknown): error is OpenTabHttpError {
|
|
78
|
+
if (typeof error !== 'object' || error === null) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const candidate = error as { status?: unknown; response?: unknown }
|
|
83
|
+
return typeof candidate.status === 'number' && 'response' in candidate
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
/**
|
|
57
87
|
* Configuration for registering a payment scheme with a specific network
|
|
58
88
|
*/
|
|
@@ -118,13 +148,14 @@ export function paymentMiddlewareFromHTTPServer(
|
|
|
118
148
|
// Dynamically register bazaar extension if routes declare it and not already registered
|
|
119
149
|
// Skip if pre-registered (e.g., in serverless environments where static imports are used)
|
|
120
150
|
let bazaarPromise: Promise<void> | null = null
|
|
151
|
+
const httpServerInternals = getHTTPServerInternals(httpServer)
|
|
121
152
|
if (
|
|
122
|
-
checkIfBazaarNeeded(
|
|
123
|
-
!
|
|
153
|
+
checkIfBazaarNeeded(httpServerInternals.routesConfig) &&
|
|
154
|
+
!httpServerInternals.ResourceServer.hasExtension('bazaar')
|
|
124
155
|
) {
|
|
125
156
|
bazaarPromise = import('@x402/extensions/bazaar')
|
|
126
157
|
.then(({ bazaarResourceServerExtension }) => {
|
|
127
|
-
|
|
158
|
+
httpServerInternals.ResourceServer.registerExtension(bazaarResourceServerExtension)
|
|
128
159
|
})
|
|
129
160
|
.catch((err) => {
|
|
130
161
|
console.error('Failed to load bazaar extension:', err)
|
|
@@ -150,9 +181,8 @@ export function paymentMiddlewareFromHTTPServer(
|
|
|
150
181
|
// Return the response
|
|
151
182
|
return res.json(openTabResponse)
|
|
152
183
|
} catch (error) {
|
|
153
|
-
if (error
|
|
154
|
-
|
|
155
|
-
return res.status(openTabError.status).json(openTabError.response)
|
|
184
|
+
if (isOpenTabHttpError(error)) {
|
|
185
|
+
return res.status(error.status).json(error.response)
|
|
156
186
|
}
|
|
157
187
|
console.error('Failed to open tab:', error)
|
|
158
188
|
return res.status(500).json({
|
|
@@ -200,7 +230,7 @@ export function paymentMiddlewareFromHTTPServer(
|
|
|
200
230
|
// No payment needed, proceed directly to the route handler
|
|
201
231
|
return next()
|
|
202
232
|
|
|
203
|
-
case 'payment-error':
|
|
233
|
+
case 'payment-error': {
|
|
204
234
|
// Payment required but not provided or invalid
|
|
205
235
|
const { response } = result
|
|
206
236
|
res.status(response.status)
|
|
@@ -213,8 +243,9 @@ export function paymentMiddlewareFromHTTPServer(
|
|
|
213
243
|
res.json(response.body || {})
|
|
214
244
|
}
|
|
215
245
|
return
|
|
246
|
+
}
|
|
216
247
|
|
|
217
|
-
case 'payment-verified':
|
|
248
|
+
case 'payment-verified': {
|
|
218
249
|
// Payment is valid, need to wrap response for settlement
|
|
219
250
|
const { paymentPayload, paymentRequirements } = result
|
|
220
251
|
|
|
@@ -346,6 +377,7 @@ export function paymentMiddlewareFromHTTPServer(
|
|
|
346
377
|
bufferedCalls = []
|
|
347
378
|
}
|
|
348
379
|
return
|
|
380
|
+
}
|
|
349
381
|
}
|
|
350
382
|
}
|
|
351
383
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FacilitatorConfig, HTTPFacilitatorClient } from '@x402/core/server'
|
|
2
|
-
import { Network, PaymentRequirements } from '@x402/core/types'
|
|
2
|
+
import { Network, PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types'
|
|
3
3
|
|
|
4
4
|
const DEFAULT_FACILITATOR_URL = 'https://x402.4mica.xyz'
|
|
5
5
|
|
|
@@ -21,6 +21,18 @@ export interface OpenTabResponse {
|
|
|
21
21
|
nextReqId: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface CertificateResponse {
|
|
25
|
+
claims: string
|
|
26
|
+
signature: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FourMicaSettleResponse = SettleResponse & {
|
|
30
|
+
certificate?: CertificateResponse
|
|
31
|
+
txHash?: string
|
|
32
|
+
networkId?: string
|
|
33
|
+
error?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
export class OpenTabError extends Error {
|
|
25
37
|
constructor(
|
|
26
38
|
public readonly status: number,
|
|
@@ -75,6 +87,41 @@ export class FourMicaFacilitatorClient extends HTTPFacilitatorClient {
|
|
|
75
87
|
throw new Error(`Facilitator openTab failed (${response.status}): ${JSON.stringify(data)}`)
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
async settle(
|
|
91
|
+
paymentPayload: PaymentPayload,
|
|
92
|
+
paymentRequirements: PaymentRequirements
|
|
93
|
+
): Promise<FourMicaSettleResponse> {
|
|
94
|
+
let headers: Record<string, string> = {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const authHeaders = await this.createAuthHeaders('settle')
|
|
99
|
+
headers = { ...headers, ...authHeaders.headers }
|
|
100
|
+
|
|
101
|
+
const response = await fetch(`${this.url}/settle`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers,
|
|
104
|
+
body: JSON.stringify(
|
|
105
|
+
this.safeJson({
|
|
106
|
+
x402Version: paymentPayload.x402Version,
|
|
107
|
+
paymentPayload,
|
|
108
|
+
paymentRequirements,
|
|
109
|
+
})
|
|
110
|
+
),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const data = (await response.json()) as Record<string, unknown>
|
|
114
|
+
const normalized = normalizeSettleResponse(data, paymentRequirements)
|
|
115
|
+
|
|
116
|
+
if (!response.ok || !normalized.success) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Facilitator settle failed (${response.status}): ${normalized.errorReason ?? normalized.error ?? 'unknown error'}`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return normalized
|
|
123
|
+
}
|
|
124
|
+
|
|
78
125
|
/**
|
|
79
126
|
* Helper to convert objects to JSON-safe format.
|
|
80
127
|
* Handles BigInt and other non-JSON types.
|
|
@@ -88,3 +135,70 @@ export class FourMicaFacilitatorClient extends HTTPFacilitatorClient {
|
|
|
88
135
|
)
|
|
89
136
|
}
|
|
90
137
|
}
|
|
138
|
+
|
|
139
|
+
function normalizeSettleResponse(
|
|
140
|
+
payload: Record<string, unknown>,
|
|
141
|
+
requirements: PaymentRequirements
|
|
142
|
+
): FourMicaSettleResponse {
|
|
143
|
+
const transaction = String(
|
|
144
|
+
payload.transaction ??
|
|
145
|
+
payload.transactionHash ??
|
|
146
|
+
payload.txHash ??
|
|
147
|
+
payload.tx_hash ??
|
|
148
|
+
payload.hash ??
|
|
149
|
+
''
|
|
150
|
+
)
|
|
151
|
+
const network = String(
|
|
152
|
+
payload.network ?? payload.networkId ?? payload.network_id ?? requirements.network
|
|
153
|
+
) as Network
|
|
154
|
+
const errorReason =
|
|
155
|
+
typeof payload.errorReason === 'string'
|
|
156
|
+
? payload.errorReason
|
|
157
|
+
: typeof payload.error_reason === 'string'
|
|
158
|
+
? payload.error_reason
|
|
159
|
+
: typeof payload.error === 'string'
|
|
160
|
+
? payload.error
|
|
161
|
+
: typeof payload.message === 'string'
|
|
162
|
+
? payload.message
|
|
163
|
+
: undefined
|
|
164
|
+
|
|
165
|
+
const certificate =
|
|
166
|
+
payload.certificate &&
|
|
167
|
+
typeof payload.certificate === 'object' &&
|
|
168
|
+
typeof (payload.certificate as Record<string, unknown>).claims === 'string' &&
|
|
169
|
+
typeof (payload.certificate as Record<string, unknown>).signature === 'string'
|
|
170
|
+
? {
|
|
171
|
+
claims: (payload.certificate as Record<string, string>).claims,
|
|
172
|
+
signature: (payload.certificate as Record<string, string>).signature,
|
|
173
|
+
}
|
|
174
|
+
: undefined
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
success: Boolean(payload.success ?? errorReason === undefined),
|
|
178
|
+
errorReason,
|
|
179
|
+
payer:
|
|
180
|
+
typeof payload.payer === 'string'
|
|
181
|
+
? payload.payer
|
|
182
|
+
: typeof payload.userAddress === 'string'
|
|
183
|
+
? payload.userAddress
|
|
184
|
+
: typeof payload.user_address === 'string'
|
|
185
|
+
? payload.user_address
|
|
186
|
+
: undefined,
|
|
187
|
+
transaction,
|
|
188
|
+
network,
|
|
189
|
+
certificate,
|
|
190
|
+
txHash:
|
|
191
|
+
typeof payload.txHash === 'string'
|
|
192
|
+
? payload.txHash
|
|
193
|
+
: typeof payload.tx_hash === 'string'
|
|
194
|
+
? payload.tx_hash
|
|
195
|
+
: transaction || undefined,
|
|
196
|
+
networkId:
|
|
197
|
+
typeof payload.networkId === 'string'
|
|
198
|
+
? payload.networkId
|
|
199
|
+
: typeof payload.network_id === 'string'
|
|
200
|
+
? payload.network_id
|
|
201
|
+
: network,
|
|
202
|
+
error: typeof payload.error === 'string' ? payload.error : undefined,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
|
+
|
|
4
|
+
import { FourMicaEvmScheme } from '../src/client/scheme.js'
|
|
5
|
+
|
|
6
|
+
describe('FourMicaEvmScheme', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('passes V2 resource metadata through to the SDK flow', async () => {
|
|
12
|
+
const signPaymentV2 = vi.fn().mockResolvedValue({
|
|
13
|
+
payload: {
|
|
14
|
+
claims: {
|
|
15
|
+
version: 'v2',
|
|
16
|
+
validation_request_hash: '0x' + '11'.repeat(32),
|
|
17
|
+
validation_subject_hash: '0x' + '22'.repeat(32),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
vi.spyOn(FourMicaEvmScheme as never, 'createX402Flow' as never).mockResolvedValue({
|
|
23
|
+
signPayment: vi.fn(),
|
|
24
|
+
signPaymentV2,
|
|
25
|
+
} as never)
|
|
26
|
+
|
|
27
|
+
const scheme = await FourMicaEvmScheme.create(privateKeyToAccount(`0x${'11'.repeat(32)}`))
|
|
28
|
+
|
|
29
|
+
const requirements = {
|
|
30
|
+
scheme: '4mica-credit',
|
|
31
|
+
network: 'eip155:11155111',
|
|
32
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
33
|
+
amount: '10',
|
|
34
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
35
|
+
extra: {
|
|
36
|
+
rpcUrl: 'https://custom.rpc.example',
|
|
37
|
+
validationRegistryAddress: '0x3333333333333333333333333333333333333333',
|
|
38
|
+
validatorAddress: '0x4444444444444444444444444444444444444444',
|
|
39
|
+
validatorAgentId: '7',
|
|
40
|
+
minValidationScore: 80,
|
|
41
|
+
requiredValidationTag: 'hard-finality',
|
|
42
|
+
resource: {
|
|
43
|
+
url: 'https://api.example.com/premium',
|
|
44
|
+
description: 'Premium dataset',
|
|
45
|
+
mimeType: 'application/json',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await scheme.createPaymentPayload(2, requirements as never)
|
|
51
|
+
|
|
52
|
+
expect(result.x402Version).toBe(2)
|
|
53
|
+
expect(signPaymentV2).toHaveBeenCalledTimes(1)
|
|
54
|
+
|
|
55
|
+
const paymentRequired = signPaymentV2.mock.calls[0]?.[0]
|
|
56
|
+
expect(paymentRequired.resource).toEqual({
|
|
57
|
+
url: 'https://api.example.com/premium',
|
|
58
|
+
description: 'Premium dataset',
|
|
59
|
+
mimeType: 'application/json',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const accepted = signPaymentV2.mock.calls[0]?.[1]
|
|
63
|
+
expect(accepted).toMatchObject({
|
|
64
|
+
scheme: '4mica-credit',
|
|
65
|
+
network: 'eip155:11155111',
|
|
66
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
67
|
+
amount: '10',
|
|
68
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
69
|
+
})
|
|
70
|
+
expect(accepted.extra).toMatchObject({
|
|
71
|
+
validationRegistryAddress: '0x3333333333333333333333333333333333333333',
|
|
72
|
+
validatorAddress: '0x4444444444444444444444444444444444444444',
|
|
73
|
+
validatorAgentId: '7',
|
|
74
|
+
minValidationScore: 80,
|
|
75
|
+
requiredValidationTag: 'hard-finality',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects unsupported x402 versions', async () => {
|
|
80
|
+
vi.spyOn(FourMicaEvmScheme as never, 'createX402Flow' as never).mockResolvedValue({
|
|
81
|
+
signPayment: vi.fn(),
|
|
82
|
+
signPaymentV2: vi.fn(),
|
|
83
|
+
} as never)
|
|
84
|
+
|
|
85
|
+
const scheme = await FourMicaEvmScheme.create(privateKeyToAccount(`0x${'11'.repeat(32)}`))
|
|
86
|
+
|
|
87
|
+
await expect(
|
|
88
|
+
scheme.createPaymentPayload(3, {
|
|
89
|
+
scheme: '4mica-credit',
|
|
90
|
+
network: 'eip155:11155111',
|
|
91
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
92
|
+
amount: '10',
|
|
93
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
94
|
+
} as never)
|
|
95
|
+
).rejects.toThrow('Unsupported x402Version: 3')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { FourMicaFacilitatorClient } from '../src/server/facilitator.js'
|
|
4
|
+
|
|
5
|
+
describe('FourMicaFacilitatorClient', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
vi.unstubAllGlobals()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('normalizes 4mica settle responses and preserves certificate fields', async () => {
|
|
12
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
13
|
+
new Response(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
success: true,
|
|
16
|
+
txHash: '0xdeadbeef',
|
|
17
|
+
networkId: 'eip155:11155111',
|
|
18
|
+
certificate: {
|
|
19
|
+
claims: '0x' + '11'.repeat(32),
|
|
20
|
+
signature: '0x' + '22'.repeat(96),
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
{
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
31
|
+
|
|
32
|
+
const client = new FourMicaFacilitatorClient({ url: 'https://facilitator.example' })
|
|
33
|
+
|
|
34
|
+
const result = await client.settle(
|
|
35
|
+
{
|
|
36
|
+
x402Version: 2,
|
|
37
|
+
accepted: {
|
|
38
|
+
scheme: '4mica-credit',
|
|
39
|
+
network: 'eip155:11155111',
|
|
40
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
41
|
+
amount: '10',
|
|
42
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
43
|
+
},
|
|
44
|
+
payload: {
|
|
45
|
+
claims: {
|
|
46
|
+
version: 'v2',
|
|
47
|
+
},
|
|
48
|
+
signature: '0x1234',
|
|
49
|
+
scheme: 'eip712',
|
|
50
|
+
},
|
|
51
|
+
} as never,
|
|
52
|
+
{
|
|
53
|
+
scheme: '4mica-credit',
|
|
54
|
+
network: 'eip155:11155111',
|
|
55
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
56
|
+
amount: '10',
|
|
57
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
58
|
+
} as never
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true)
|
|
62
|
+
expect(result.transaction).toBe('0xdeadbeef')
|
|
63
|
+
expect(result.network).toBe('eip155:11155111')
|
|
64
|
+
expect(result.txHash).toBe('0xdeadbeef')
|
|
65
|
+
expect(result.networkId).toBe('eip155:11155111')
|
|
66
|
+
expect(result.certificate).toEqual({
|
|
67
|
+
claims: '0x' + '11'.repeat(32),
|
|
68
|
+
signature: '0x' + '22'.repeat(96),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const request = fetchMock.mock.calls[0]?.[1]
|
|
72
|
+
expect(typeof request?.body).toBe('string')
|
|
73
|
+
expect(String(request?.body)).toContain('"x402Version":2')
|
|
74
|
+
expect(String(request?.body)).toContain('"version":"v2"')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('normalizes alias fields when the facilitator omits txHash/networkId', async () => {
|
|
78
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
79
|
+
new Response(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
success: true,
|
|
82
|
+
transactionHash: '0xabc123',
|
|
83
|
+
network: 'eip155:80002',
|
|
84
|
+
user_address: '0x9999999999999999999999999999999999999999',
|
|
85
|
+
}),
|
|
86
|
+
{
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { 'content-type': 'application/json' },
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
94
|
+
|
|
95
|
+
const client = new FourMicaFacilitatorClient({ url: 'https://facilitator.example' })
|
|
96
|
+
const result = await client.settle(
|
|
97
|
+
{
|
|
98
|
+
x402Version: 2,
|
|
99
|
+
accepted: {
|
|
100
|
+
scheme: '4mica-credit',
|
|
101
|
+
network: 'eip155:80002',
|
|
102
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
103
|
+
amount: '10',
|
|
104
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
105
|
+
},
|
|
106
|
+
payload: {
|
|
107
|
+
claims: { version: 'v2' },
|
|
108
|
+
signature: '0x1234',
|
|
109
|
+
scheme: 'eip712',
|
|
110
|
+
},
|
|
111
|
+
} as never,
|
|
112
|
+
{
|
|
113
|
+
scheme: '4mica-credit',
|
|
114
|
+
network: 'eip155:80002',
|
|
115
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
116
|
+
amount: '10',
|
|
117
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
118
|
+
} as never
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(true)
|
|
122
|
+
expect(result.transaction).toBe('0xabc123')
|
|
123
|
+
expect(result.txHash).toBe('0xabc123')
|
|
124
|
+
expect(result.network).toBe('eip155:80002')
|
|
125
|
+
expect(result.networkId).toBe('eip155:80002')
|
|
126
|
+
expect(result.payer).toBe('0x9999999999999999999999999999999999999999')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('surfaces facilitator errors using normalized reasons', async () => {
|
|
130
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
131
|
+
new Response(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
success: false,
|
|
134
|
+
error_reason: 'unsupported x402Version 2',
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
144
|
+
|
|
145
|
+
const client = new FourMicaFacilitatorClient({ url: 'https://facilitator.example' })
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
client.settle(
|
|
149
|
+
{
|
|
150
|
+
x402Version: 2,
|
|
151
|
+
accepted: {
|
|
152
|
+
scheme: '4mica-credit',
|
|
153
|
+
network: 'eip155:11155111',
|
|
154
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
155
|
+
amount: '10',
|
|
156
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
157
|
+
},
|
|
158
|
+
payload: {
|
|
159
|
+
claims: { version: 'v2' },
|
|
160
|
+
signature: '0x1234',
|
|
161
|
+
scheme: 'eip712',
|
|
162
|
+
},
|
|
163
|
+
} as never,
|
|
164
|
+
{
|
|
165
|
+
scheme: '4mica-credit',
|
|
166
|
+
network: 'eip155:11155111',
|
|
167
|
+
asset: '0x2222222222222222222222222222222222222222',
|
|
168
|
+
amount: '10',
|
|
169
|
+
payTo: '0x1111111111111111111111111111111111111111',
|
|
170
|
+
} as never
|
|
171
|
+
)
|
|
172
|
+
).rejects.toThrow('unsupported x402Version 2')
|
|
173
|
+
})
|
|
174
|
+
})
|