Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 23 additions & 5 deletions e2e/clients/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { createPublicClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base, baseSepolia } from "viem/chains";
import { ExactEvmScheme, type ExactEvmSchemeOptions } from "@x402/evm/exact/client";
import { UptoEvmScheme as UptoEvmClientScheme, type UptoEvmSchemeOptions } from "@x402/evm/upto/client";
import {
UptoEvmScheme as UptoEvmClientScheme,
type UptoEvmSchemeOptions,
} from "@x402/evm/upto/client";
import { ExactEvmSchemeV1 } from "@x402/evm/v1";
import { toClientEvmSigner } from "@x402/evm";
import { ExactSvmScheme } from "@x402/svm/exact/client";
Expand All @@ -23,7 +26,9 @@ const baseURL = process.env.RESOURCE_SERVER_URL as string;
const endpointPath = process.env.ENDPOINT_PATH as string;
const url = `${baseURL}${endpointPath}`;
const evmAccount = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`);
const svmSigner = await createKeyPairSignerFromBytes(base58.decode(process.env.SVM_PRIVATE_KEY as string));
const svmSigner = await createKeyPairSignerFromBytes(
base58.decode(process.env.SVM_PRIVATE_KEY as string),
);

const evmNetwork = process.env.EVM_NETWORK || "eip155:84532";
const evmRpcUrl = process.env.EVM_RPC_URL;
Expand All @@ -47,7 +52,10 @@ const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = process.env.EVM_RPC_
// Initialize Aptos signer if key is provided
let aptosAccount: Account | undefined;
if (process.env.APTOS_PRIVATE_KEY) {
const formattedKey = PrivateKey.formatPrivateKey(process.env.APTOS_PRIVATE_KEY, PrivateKeyVariants.Ed25519);
const formattedKey = PrivateKey.formatPrivateKey(
process.env.APTOS_PRIVATE_KEY,
PrivateKeyVariants.Ed25519,
);
const aptosPrivateKey = new Ed25519PrivateKey(formattedKey);
aptosAccount = Account.fromPrivateKey({ privateKey: aptosPrivateKey });
}
Expand All @@ -73,20 +81,29 @@ if (stellarSigner) {
client.register("stellar:*", new ExactStellarScheme(stellarSigner));
}

const fetchWithPayment = wrapFetchWithPayment(fetch, client);
let capturedPaymentRequired: unknown;

const httpClient = new x402HTTPClient(client).onPaymentRequired(async ({ paymentRequired }) => {
capturedPaymentRequired = paymentRequired;
});

const fetchWithPayment = wrapFetchWithPayment(fetch, httpClient);

fetchWithPayment(url, {
method: "GET",
}).then(async response => {
const data = await response.json();
const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse((name) => response.headers.get(name));
const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name =>
response.headers.get(name),
);

if (!paymentResponse) {
// No payment was required
const result = {
success: true,
data: data,
status_code: response.status,
payment_required: capturedPaymentRequired,
};
console.log(JSON.stringify(result));
process.exit(0);
Expand All @@ -97,6 +114,7 @@ fetchWithPayment(url, {
success: paymentResponse.success,
data: data,
status_code: response.status,
payment_required: capturedPaymentRequired,
payment_response: paymentResponse,
};

Expand Down
1 change: 1 addition & 0 deletions e2e/clients/fetch/test.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
]
},
"extensions": [
"operation-binding",
"eip2612GasSponsoring",
"erc20ApprovalGasSponsoring"
],
Expand Down
153 changes: 153 additions & 0 deletions e2e/extensions/operation-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { ScenarioResult, TestEndpoint, TestConfig } from "../src/types";

const OPERATION_BINDING = "operation-binding";
const OPERATION_BINDING_PATH = "/exact/evm/operation-binding";
const OPERATION_BINDING_OPERATION_ID = "exact.evm.operationBinding";
const OPERATION_BINDING_POLICY_VERSION = "2026-04-04";

interface OperationBindingValidationResult {
success: boolean;
error?: string;
}

/**
* Check whether the scenario endpoint declares the operation-binding extension.
*
* @param endpoint - Endpoint metadata from the E2E scenario.
* @returns `true` when the endpoint opts into operation-binding validation.
*/
function endpointRequiresOperationBinding(endpoint: TestEndpoint): boolean {
return endpoint.extensions?.includes(OPERATION_BINDING) ?? false;
}

/**
* Check whether the client explicitly supports surfacing operation-binding details.
*
* @param clientConfig - E2E client configuration metadata.
* @returns `true` when the client can expose PaymentRequired extension data.
*/
export function clientSupportsOperationBinding(
clientConfig: TestConfig,
): boolean {
return clientConfig.extensions?.includes(OPERATION_BINDING) ?? false;
}

/**
* Check whether a scenario should run operation-binding validation.
*
* @param selectedExtensions - Extension output flags selected for the run.
* @param endpoint - Endpoint metadata from the scenario.
* @param clientConfig - Client metadata from the scenario.
* @returns `true` when operation-binding should be validated for the scenario.
*/
export function shouldValidateOperationBinding(
selectedExtensions: string[] | undefined,
endpoint: TestEndpoint,
clientConfig: TestConfig,
): boolean {
if (!selectedExtensions?.includes(OPERATION_BINDING)) {
return false;
}

if (!endpointRequiresOperationBinding(endpoint)) {
return false;
}

return clientSupportsOperationBinding(clientConfig);
}

/**
* Validate the operation-binding declaration captured from a live PaymentRequired response.
*
* @param result - Client result containing the captured PaymentRequired payload.
* @param endpoint - Endpoint metadata from the scenario.
* @returns Validation result.
*/
export function validateOperationBindingResult(
result: ScenarioResult,
endpoint: TestEndpoint,
): OperationBindingValidationResult {
const paymentRequired = result.payment_required;
if (!paymentRequired || typeof paymentRequired !== "object") {
return {
success: false,
error:
"Client did not capture the PaymentRequired payload for operation-binding validation.",
};
}

const extensions = (
paymentRequired as { extensions?: Record<string, unknown> }
).extensions;
const extensionValue = extensions?.[OPERATION_BINDING];
if (!extensionValue || typeof extensionValue !== "object") {
return {
success: false,
error:
"PaymentRequired response is missing the operation-binding extension.",
};
}

const info = (extensionValue as { info?: Record<string, unknown> }).info;
if (!info || typeof info !== "object") {
return {
success: false,
error: "Operation-binding extension is missing its info payload.",
};
}

const expectedPath = endpoint.path.split("?")[0];

const expectedFields: Array<[keyof typeof info, unknown]> = [
["transport", "http"],
["method", endpoint.method],
["pathTemplate", expectedPath],
["operationId", OPERATION_BINDING_OPERATION_ID],
["policyVersion", OPERATION_BINDING_POLICY_VERSION],
["canonicalization", "rfc8785-jcs"],
["digestAlgorithm", "sha-256"],
["bindPathParams", true],
["bindQuery", true],
["bindBody", false],
];

for (const [field, expectedValue] of expectedFields) {
if (info[field] !== expectedValue) {
return {
success: false,
error: `Operation-binding field ${String(field)} was ${JSON.stringify(info[field])}, expected ${JSON.stringify(expectedValue)}.`,
};
}
}

const resourceUrl = info.resourceUrl;
if (typeof resourceUrl !== "string") {
return {
success: false,
error: "Operation-binding resourceUrl is missing or not a string.",
};
}

const url = new URL(resourceUrl);
if (url.pathname !== expectedPath) {
return {
success: false,
error: `Operation-binding resourceUrl pathname was ${url.pathname}, expected ${expectedPath}.`,
};
}

if (expectedPath === OPERATION_BINDING_PATH) {
if (
url.searchParams.get("units") !== "metric" ||
url.searchParams.get("lang") !== "en"
) {
return {
success: false,
error:
"Operation-binding resourceUrl did not preserve the expected query parameters.",
};
}
}

return { success: true };
}
Loading
Loading