Skip to content

Commit 9a22a1c

Browse files
committed
refund without paid request
1 parent 3c29cbe commit 9a22a1c

20 files changed

Lines changed: 1147 additions & 167 deletions

File tree

e2e/clients/axios/index.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,9 @@ if (avmSigner) {
111111

112112
const axiosWithPayment = wrapAxiosWithPayment(axios.create(), client);
113113

114-
// Multi-request scenarios (used by batch-settlement) issue several paid requests
115-
// against the same endpoint so the server can amortise on-chain claims, then
116-
// optionally signal a cooperative refund on the last request.
114+
// Multi-request scenarios (used by batch-settlement)
117115
const numberOfRequests = Number.parseInt(process.env.MULTI_REQUEST_COUNT ?? "1", 10);
118-
const refundOnLastRequest = process.env.REFUND_ON_LAST === "true";
116+
const refundAfterRequests = process.env.REFUND_ON_LAST ?? "true";
119117

120118
/**
121119
* Issues a single paid request and returns the parsed result.
@@ -147,21 +145,19 @@ async function issueRequest(): Promise<{
147145

148146
try {
149147
const results: Awaited<ReturnType<typeof issueRequest>>[] = [];
150-
let lastChannelId: string | undefined;
151148
for (let i = 0; i < numberOfRequests; i++) {
152-
const isLast = i === numberOfRequests - 1;
153-
154-
if (isLast && refundOnLastRequest && lastChannelId) {
155-
batchSettlementScheme.requestRefund(lastChannelId);
156-
}
157-
158149
const result = await issueRequest();
159150
results.push(result);
151+
}
160152

161-
const channelId = result.payment_response?.extra?.channelId;
162-
if (typeof channelId === "string" && channelId.length > 0) {
163-
lastChannelId = channelId;
164-
}
153+
if (refundAfterRequests) {
154+
const refundSettle = await batchSettlementScheme.refund(url);
155+
results.push({
156+
success: refundSettle.success,
157+
data: { refund: true },
158+
status_code: 200,
159+
payment_response: refundSettle,
160+
});
165161
}
166162

167163
const last = results[results.length - 1]!;

e2e/clients/fetch/index.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,9 @@ if (avmSigner) {
106106
const fetchWithPayment = wrapFetchWithPayment(fetch, client);
107107
const httpClient = new x402HTTPClient(client);
108108

109-
// Multi-request scenarios (used by batch-settlement) issue several paid requests
110-
// against the same endpoint so the server can amortise on-chain claims, then
111-
// optionally signal a cooperative refund on the last request.
109+
// Multi-request scenarios (used by batch-settlement)
112110
const numberOfRequests = Number.parseInt(process.env.MULTI_REQUEST_COUNT ?? "1", 10);
113-
const refundOnLastRequest = process.env.REFUND_ON_LAST === "true";
111+
const refundAfterRequests = process.env.REFUND_ON_LAST ?? "true";
114112

115113
/**
116114
* Issues a single paid request and returns the parsed result.
@@ -140,21 +138,19 @@ async function issueRequest(): Promise<{
140138
}
141139

142140
const results: Awaited<ReturnType<typeof issueRequest>>[] = [];
143-
let lastChannelId: string | undefined;
144141
for (let i = 0; i < numberOfRequests; i++) {
145-
const isLast = i === numberOfRequests - 1;
146-
147-
if (isLast && refundOnLastRequest && lastChannelId) {
148-
batchSettlementScheme.requestRefund(lastChannelId);
149-
}
150-
151142
const result = await issueRequest();
152143
results.push(result);
144+
}
153145

154-
const channelId = result.payment_response?.extra?.channelId;
155-
if (typeof channelId === "string" && channelId.length > 0) {
156-
lastChannelId = channelId;
157-
}
146+
if (refundAfterRequests) {
147+
const refundSettle = await batchSettlementScheme.refund(url);
148+
results.push({
149+
success: refundSettle.success,
150+
data: { refund: true },
151+
status_code: 200,
152+
payment_response: refundSettle,
153+
});
158154
}
159155

160156
const last = results[results.length - 1]!;

examples/typescript/clients/batch-settlement/.env-local

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
RESOURCE_SERVER_URL=http://localhost:4021
22
ENDPOINT_PATH=/api/generate
33
NUMBER_OF_REQUESTS=
4+
REFUND_AFTER_REQUESTS=
5+
REFUND_AMOUNT=
46

57
EVM_PRIVATE_KEY=
68
EVM_VOUCHER_SIGNER_PRIVATE_KEY=

examples/typescript/clients/batch-settlement/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
Fetch-based client that pays for a sequence of `GET /api/generate` requests over a single payment channel using the **batch-settlement** EVM scheme. The first request opens the channel with a deposit; subsequent requests pay with a fresh cumulative voucher.
44

5-
Optionally requests a cooperative refund on the last call (`REFUND_ON_LAST_REQUEST=true`), which signals the server to claim outstanding vouchers and return the unclaimed balance.
65

76
See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details.
87

@@ -50,4 +49,4 @@ pnpm start
5049
| `CHANNEL_SALT` | no | `bytes32` salt for channel id; change to open a fresh channel |
5150
| `STORAGE_DIR` | no | Persist client session state (defaults to in-memory) |
5251
| `NUMBER_OF_REQUESTS` | no | How many paid requests to issue (default 3) |
53-
| `REFUND_ON_LAST_REQUEST` | no | If `true`, request a cooperative refund on the last call |
52+
| `REFUND_AFTER_REQUESTS` | no | If `true`, issue a self-contained refund via `scheme.refund(url)` after the request loop |

examples/typescript/clients/batch-settlement/index.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const storageDir = process.env.STORAGE_DIR ?? process.env.STORAGE_DIR_DIR;
3232
const channelSalt = (process.env.CHANNEL_SALT ??
3333
"0x0000000000000000000000000000000000000000000000000000000000000000") as `0x${string}`;
3434
const numberOfRequests = Number(process.env.NUMBER_OF_REQUESTS ?? "3");
35-
const refundOnLastRequest = process.env.REFUND_ON_LAST_REQUEST === "true";
35+
const refundAfterRequests = process.env.REFUND_AFTER_REQUESTS === "true";
36+
const refundAmount = process.env.REFUND_AMOUNT;
3637

3738
/**
3839
* Runs sequential paid requests against the configured resource server endpoint.
@@ -68,38 +69,42 @@ async function main(): Promise<void> {
6869
const fetchWithPayment = wrapFetchWithPayment(fetch, client);
6970
const httpClient = new x402HTTPClient(client);
7071

71-
let channelId: string | undefined;
72-
7372
console.log(`Base URL: ${baseURL}, endpoint: ${endpointPath}`);
7473
console.log("payer:", signer.address);
7574
console.log("payerAuthorizer:", voucherSigner?.address ?? signer.address, "\n");
7675

7776
for (let i = 0; i < numberOfRequests; i++) {
7877
const requestT0 = performance.now();
7978

80-
if (refundOnLastRequest && i === numberOfRequests - 1 && channelId) {
81-
console.log(`REQUESTING REFUND`);
82-
batchedScheme.requestRefund(channelId);
83-
}
84-
8579
const response = await fetchWithPayment(url, { method: "GET" });
8680
const result = await httpClient.processResponse(response);
8781

8882
if (result.kind === "success") {
8983
console.log(`Request ${i + 1} — RESPONSE`);
9084
console.log(result.body);
9185
console.log(JSON.stringify(result.settleResponse, null, 2));
92-
if (
93-
result.settleResponse.extra &&
94-
typeof result.settleResponse.extra.channelId === "string"
95-
) {
96-
channelId = result.settleResponse.extra.channelId;
97-
}
86+
} else {
87+
console.log(`Request ${i + 1}${result.kind}`);
88+
console.log(JSON.stringify(result, null, 2));
9889
}
9990
console.log(
10091
`Request ${i + 1} — completed in ${formatSeconds(performance.now() - requestT0)}s\n`,
10192
);
10293
}
94+
95+
if (refundAfterRequests) {
96+
console.log(
97+
refundAmount
98+
? `REQUESTING PARTIAL REFUND of ${refundAmount} base units`
99+
: "REQUESTING FULL REFUND of remaining channel balance",
100+
);
101+
const refundT0 = performance.now();
102+
const settle = await batchedScheme.refund(url, {
103+
...(refundAmount ? { amount: refundAmount } : {}),
104+
});
105+
console.log(JSON.stringify(settle, null, 2));
106+
console.log(`Refund completed in ${formatSeconds(performance.now() - refundT0)}s`);
107+
}
103108
}
104109

105110
main().catch(error => {

typescript/packages/core/src/http/x402HTTPResourceServer.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { x402ResourceServer, SettlementOverrides } from "../server";
1+
import { x402ResourceServer, SettlementOverrides, SkipHandlerDirective } from "../server";
22
import {
33
decodePaymentSignatureHeader,
44
encodePaymentRequiredHeader,
@@ -546,6 +546,17 @@ export class x402HTTPResourceServer {
546546
};
547547
}
548548

549+
// Bypass the resource handler
550+
if (verifyResult.skipHandler) {
551+
return await this.processSkipHandlerSettlement(
552+
paymentPayload,
553+
matchingRequirements,
554+
routeConfig.extensions,
555+
transportContext,
556+
verifyResult.skipHandler,
557+
);
558+
}
559+
549560
// Payment is valid, return data needed for settlement
550561
return {
551562
type: "payment-verified",
@@ -694,6 +705,56 @@ export class x402HTTPResourceServer {
694705
return this.getRouteConfig(context.path, method) !== undefined;
695706
}
696707

708+
/**
709+
* Settle a verified payment that requested `skipHandler`, packaging the
710+
* result as a `payment-error` HTTPProcessResult so framework adapters can
711+
* write the response without invoking the route handler.
712+
*
713+
* - On success: status 200 + PAYMENT-RESPONSE header + configured body.
714+
* - On failure: the standard 402 settlement-failure response.
715+
*
716+
* @param paymentPayload - Verified payment payload.
717+
* @param requirements - Matched payment requirements.
718+
* @param declaredExtensions - Optional declared extensions for the route.
719+
* @param transportContext - Optional HTTP transport context.
720+
* @param skipHandlerResponse - Optional content type + body to return on success.
721+
* @returns A `payment-error` HTTPProcessResult carrying the final response.
722+
*/
723+
private async processSkipHandlerSettlement(
724+
paymentPayload: PaymentPayload,
725+
requirements: PaymentRequirements,
726+
declaredExtensions: Record<string, unknown> | undefined,
727+
transportContext: HTTPTransportContext,
728+
skipHandlerResponse: SkipHandlerDirective | undefined,
729+
): Promise<HTTPProcessResult> {
730+
const settleResult = await this.processSettlement(
731+
paymentPayload,
732+
requirements,
733+
declaredExtensions,
734+
transportContext,
735+
);
736+
737+
if (!settleResult.success) {
738+
return { type: "payment-error", response: settleResult.response };
739+
}
740+
741+
const contentType = skipHandlerResponse?.contentType ?? "application/json";
742+
const body = skipHandlerResponse?.body ?? {};
743+
744+
return {
745+
type: "payment-error",
746+
response: {
747+
status: 200,
748+
headers: {
749+
"Content-Type": contentType,
750+
...settleResult.headers,
751+
},
752+
body,
753+
isHtml: contentType.includes("text/html"),
754+
},
755+
};
756+
}
757+
697758
/**
698759
* Build HTTPResponseInstructions for settlement failure.
699760
* Uses settlementFailedResponseBody hook if configured, otherwise defaults to empty body.

typescript/packages/core/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
SettleResultContext,
1010
SettleFailureContext,
1111
SettlementOverrides,
12+
SkipHandlerDirective,
1213
BeforeVerifyHook,
1314
AfterVerifyHook,
1415
OnVerifyFailureHook,

typescript/packages/core/src/server/x402ResourceServer.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ export interface VerifyResultContext extends VerifyContext {
5151
result: VerifyResponse;
5252
}
5353

54+
/**
55+
* Optional acknowledgement body returned to the caller when an `AfterVerifyHook`
56+
* requests that the resource handler be skipped for a self-contained operation
57+
* (e.g. cooperative refund). Travels in-process only — never on the facilitator wire.
58+
*/
59+
export interface SkipHandlerDirective {
60+
contentType?: string;
61+
body?: unknown;
62+
}
63+
5464
export interface VerifyFailureContext extends VerifyContext {
5565
error: Error;
5666
}
@@ -77,7 +87,9 @@ export type BeforeVerifyHook = (
7787
context: VerifyContext,
7888
) => Promise<void | { abort: true; reason: string; message?: string }>;
7989

80-
export type AfterVerifyHook = (context: VerifyResultContext) => Promise<void>;
90+
export type AfterVerifyHook = (
91+
context: VerifyResultContext,
92+
) => Promise<void | { skipHandler: true; response?: SkipHandlerDirective }>;
8193

8294
export type OnVerifyFailureHook = (
8395
context: VerifyFailureContext,
@@ -678,16 +690,22 @@ export class x402ResourceServer {
678690
}
679691

680692
/**
681-
* Verify a payment against requirements
693+
* Verify a payment against requirements.
694+
*
695+
* The returned object is a {@link VerifyResponse} (the wire-level type),
696+
* optionally extended with an in-process `skipHandler` directive when an
697+
* `AfterVerifyHook` requested that the resource handler be bypassed. The
698+
* extra property is invisible to callers that only read standard fields and
699+
* is never serialized to the facilitator wire.
682700
*
683701
* @param paymentPayload - The payment payload to verify
684702
* @param requirements - The payment requirements
685-
* @returns Verification response
703+
* @returns Verification response, optionally carrying a `skipHandler` directive
686704
*/
687705
async verifyPayment(
688706
paymentPayload: PaymentPayload,
689707
requirements: PaymentRequirements,
690-
): Promise<VerifyResponse> {
708+
): Promise<VerifyResponse & { skipHandler?: SkipHandlerDirective }> {
691709
const context: VerifyContext = {
692710
paymentPayload,
693711
requirements,
@@ -749,17 +767,26 @@ export class x402ResourceServer {
749767
verifyResult = await facilitatorClient.verify(paymentPayload, requirements);
750768
}
751769

752-
// Execute afterVerify hooks
770+
// Execute afterVerify hooks. The last hook to return a `skipHandler`
771+
// directive wins; this lets schemes signal that a self-contained
772+
// operation (e.g. cooperative refund) should bypass the resource
773+
// handler and settle inline. The directive is attached as an extra
774+
// optional property on the returned response — the wire-level
775+
// `VerifyResponse` is never modified.
753776
const resultContext: VerifyResultContext = {
754777
...context,
755778
result: verifyResult,
756779
};
757780

781+
let skipHandler: SkipHandlerDirective | undefined;
758782
for (const hook of this.afterVerifyHooks) {
759-
await hook(resultContext);
783+
const directive = await hook(resultContext);
784+
if (directive && "skipHandler" in directive && directive.skipHandler) {
785+
skipHandler = directive.response ?? {};
786+
}
760787
}
761788

762-
return verifyResult;
789+
return skipHandler ? { ...verifyResult, skipHandler } : verifyResult;
763790
} catch (error) {
764791
const failureContext: VerifyFailureContext = {
765792
...context,

0 commit comments

Comments
 (0)