Skip to content

Commit 42bc614

Browse files
whoabuddyclaude
andauthored
fix: align relay payment polling contract with tx-schemas (#95)
* fix: align relay payment polling contract with tx-schemas * test: lock payment contract parity surfaces * chore: align x402-api payment contract verification with released schemas * fix: address payment contract review feedback * fix: document test-only export, clarify dual logging, annotate type cast - Add comment on getRetryDecisionContext explaining it's used by test utilities, not production middleware (not dead code) - Add comment on dual payment.poll/payment.retry_decision emission explaining dashboard vs alerting consumer split - Add comment on `as unknown` cast in estimateInputTokens explaining OpenRouter SDK type narrowing mismatch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41b514d commit 42bc614

21 files changed

Lines changed: 1700 additions & 429 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ bun run tests/_run_all_tests.ts --category=hashing
7575
bun run tests/_run_all_tests.ts --filter=sha256 --all-tokens
7676
```
7777

78+
### Rollout Check
79+
80+
For the immediate-pay-per-call stabilization path, run a focused staging or production check against a cheap paid endpoint:
81+
82+
```bash
83+
export X402_WORKER_URL=https://x402.aibtc.dev
84+
bun run tests/_run_all_tests.ts --filter=sha256 --token=STX --retries=2 --verbose
85+
```
86+
87+
On any retryable 402 during this check, confirm the body stays on canonical caller-facing semantics:
88+
- `status` never returns `submitted`
89+
- `paymentId` is present before retrying the same payment
90+
- `terminalReason` is the normalized terminal signal when present
91+
- `checkStatusUrl` is treated as additive when present
92+
7893
### Test Modes
7994

8095
| Mode | Description |

package-lock.json

Lines changed: 19 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"wrangler": "^4.75.0"
2929
},
3030
"dependencies": {
31+
"@aibtc/tx-schemas": "^0.3.0",
3132
"@stacks/encryption": "^7.3.1",
3233
"@stacks/network": "^7.3.1",
3334
"@stacks/transactions": "^7.3.1",

src/endpoints/ax-discovery.ts

Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { Hono } from "hono";
1818
import type { Env, AppVariables } from "../types";
19+
import { PAYMENT_PUBLIC_LIFECYCLE } from "../utils/payment-contract";
1920

2021
// =============================================================================
2122
// Content Definitions
@@ -41,6 +42,12 @@ signs a Stacks transaction and retries with the payment signature.
4142
4243
This API supports x402 v2 (Coinbase-compatible) with Stacks blockchain payments.
4344
45+
Canonical public payment lifecycle:
46+
- \`${PAYMENT_PUBLIC_LIFECYCLE}\`
47+
- \`submitted\` is never caller-facing
48+
- relay-owned \`paymentId\` is the stable in-flight identity
49+
- this service keeps immediate pay-per-call behavior during rollout, but any surfaced payment status follows the canonical lifecycle and may include \`checkStatusUrl\`
50+
4451
## Pricing Tiers
4552
4653
| Tier | Cost | Endpoints |
@@ -184,19 +191,43 @@ Legacy headers (still accepted for backward compatibility):
184191
- \`X-PAYMENT\` (replaces payment-signature)
185192
- \`X-PAYMENT-RESPONSE\` (replaces payment-response)
186193
194+
### Canonical Payment Lifecycle
195+
196+
\`\`\`
197+
${PAYMENT_PUBLIC_LIFECYCLE}
198+
\`\`\`
199+
200+
- \`submitted\` is internal relay observability only and is never caller-facing
201+
- \`paymentId\` is relay-owned and stays stable while a payment is in-flight
202+
- x402-api still uses immediate pay-per-call delivery during this phase, so in-flight payments return retryable payment errors instead of switching to a receipt-only API
203+
- \`checkStatusUrl\` is an additive canonical polling hint when the relay provides it
204+
187205
### PaymentRequiredV2 Structure (base64-decoded)
188206
189207
\`\`\`json
190208
{
191-
"version": 2,
192-
"payTo": "SP1XXXXXXXXX",
193-
"amount": "1000",
194-
"asset": null,
195-
"network": "stacks:1",
196-
"extra": {
197-
"tier": "standard",
198-
"description": "0.001 STX per request"
199-
}
209+
"x402Version": 2,
210+
"resource": {
211+
"url": "/hashing/sha256",
212+
"description": "x402 API - /hashing/sha256",
213+
"mimeType": "application/json"
214+
},
215+
"accepts": [
216+
{
217+
"scheme": "exact",
218+
"network": "stacks:1",
219+
"amount": "1000",
220+
"asset": "STX",
221+
"payTo": "SP1XXXXXXXXX",
222+
"maxTimeoutSeconds": 300,
223+
"extra": {
224+
"pricing": {
225+
"type": "fixed",
226+
"tier": "standard"
227+
}
228+
}
229+
}
230+
]
200231
}
201232
\`\`\`
202233
@@ -206,7 +237,7 @@ Legacy headers (still accepted for backward compatibility):
206237
GET /x402.json
207238
\`\`\`
208239
Returns machine-readable payment manifest with supported tokens, pricing tiers,
209-
and relay URL. Use this to auto-configure x402-stacks clients.
240+
relay URL, and payment lifecycle metadata. Use this to auto-configure x402-stacks clients.
210241
211242
## Pricing
212243
@@ -1130,7 +1161,7 @@ The flow is:
11301161
1. Client sends request WITHOUT payment → server returns 402
11311162
2. Server response includes payment requirements (amount, recipient, token)
11321163
3. Client creates and signs a Stacks transaction
1133-
4. Client retries request WITH signed transaction → server verifies and processes
1164+
4. Client retries request WITH signed transaction → server verifies, settles, and either serves the resource or returns canonical payment status
11341165
11351166
## Step 1: Initial Request (No Payment)
11361167
@@ -1157,24 +1188,38 @@ The \`payment-required\` header is a base64-encoded JSON object:
11571188
11581189
\`\`\`json
11591190
{
1160-
"version": 2,
1161-
"payTo": "SP1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1162-
"amount": "1000",
1163-
"asset": null,
1164-
"network": "stacks:1",
1165-
"extra": {
1166-
"tier": "standard",
1167-
"description": "0.001 STX per request"
1168-
}
1191+
"x402Version": 2,
1192+
"resource": {
1193+
"url": "/hashing/sha256",
1194+
"description": "x402 API - /hashing/sha256",
1195+
"mimeType": "application/json"
1196+
},
1197+
"accepts": [
1198+
{
1199+
"scheme": "exact",
1200+
"network": "stacks:1",
1201+
"amount": "1000",
1202+
"asset": "STX",
1203+
"payTo": "SP1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1204+
"maxTimeoutSeconds": 300,
1205+
"extra": {
1206+
"pricing": {
1207+
"type": "fixed",
1208+
"tier": "standard"
1209+
}
1210+
}
1211+
}
1212+
]
11691213
}
11701214
\`\`\`
11711215
11721216
Fields:
1173-
- \`payTo\`: Stacks address to pay (SP... for mainnet, ST... for testnet)
1174-
- \`amount\`: Amount in base units (microSTX for STX, satoshis for sBTC, microUSDCx for USDCx)
1175-
- \`asset\`: null for STX, contract principal for sBTC/USDCx
1176-
- \`network\`: "stacks:1" (mainnet), "stacks:2147483648" (testnet)
1177-
- \`extra.tier\`: Pricing tier (standard, dynamic, free)
1217+
- \`resource.url\`: Resource being paid for
1218+
- \`accepts[0].payTo\`: Stacks address to pay (SP... for mainnet, ST... for testnet)
1219+
- \`accepts[0].amount\`: Amount in base units (microSTX for STX, satoshis for sBTC, microUSDCx for USDCx)
1220+
- \`accepts[0].asset\`: \`STX\` for the native token, or contract principal for sBTC/USDCx
1221+
- \`accepts[0].network\`: "stacks:1" (mainnet), "stacks:2147483648" (testnet)
1222+
- \`accepts[0].extra.pricing\`: Pricing metadata (fixed or dynamic)
11781223
11791224
## Step 3: Build Payment Payload
11801225
@@ -1191,9 +1236,23 @@ For sBTC payments:
11911236
Wrap in PaymentPayloadV2:
11921237
\`\`\`json
11931238
{
1194-
"version": 2,
1195-
"transaction": "0x8080000000040a...", // hex-encoded signed Stacks tx
1196-
"network": "stacks:1"
1239+
"x402Version": 2,
1240+
"resource": {
1241+
"url": "/hashing/sha256",
1242+
"description": "x402 API - /hashing/sha256",
1243+
"mimeType": "application/json"
1244+
},
1245+
"accepted": {
1246+
"scheme": "exact",
1247+
"network": "stacks:1",
1248+
"amount": "1000",
1249+
"asset": "STX",
1250+
"payTo": "SP1XXXXXXXXX",
1251+
"maxTimeoutSeconds": 300
1252+
},
1253+
"payload": {
1254+
"transaction": "0x8080000000040a..."
1255+
}
11971256
}
11981257
\`\`\`
11991258
@@ -1232,6 +1291,20 @@ Decode \`payment-response\` to get transaction ID:
12321291
{ "version": 2, "txId": "0xabc123..." }
12331292
\`\`\`
12341293
1294+
If the relay returns canonical in-flight or terminal failure data instead of immediate success, x402-api surfaces the same public fields when available:
1295+
1296+
\`\`\`json
1297+
{
1298+
"error": "Payment is still in flight, please retry with the same paymentId",
1299+
"code": "transaction_pending",
1300+
"paymentId": "pay_123",
1301+
"status": "queued",
1302+
"checkStatusUrl": "https://relay.example/status/pay_123"
1303+
}
1304+
\`\`\`
1305+
1306+
Terminal outcomes may also include \`terminalReason\`, for example \`sender_nonce_stale\`, \`queue_unavailable\`, \`broadcast_failure\`, \`nonce_replacement\`, or \`unknown_payment_identity\`. When the relay only exposes legacy details, x402-api uses compatibility-only inference after canonical parsing rather than making fallback inference the primary path.
1307+
12351308
## Token Types
12361309
12371310
### STX (default)
@@ -1298,11 +1371,17 @@ GET /x402.json
12981371
Machine-readable payment configuration. Use this to auto-configure x402-stacks:
12991372
\`\`\`json
13001373
{
1301-
"version": 2,
1302-
"network": "mainnet",
1303-
"payTo": "SP...",
1304-
"tokens": ["STX", "sBTC", "USDCx"],
1305-
"endpoints": [...]
1374+
"x402Version": 2,
1375+
"items": [...],
1376+
"metadata": {
1377+
"paymentLifecycle": {
1378+
"publicStates": ["requires_payment", "queued", "broadcasting", "mempool", "confirmed", "failed", "replaced", "not_found"],
1379+
"submittedCallerFacing": false,
1380+
"inFlightIdentity": "paymentId",
1381+
"deliverableState": "confirmed",
1382+
"deliveryMode": "immediate-pay-per-call-compat"
1383+
}
1384+
}
13061385
}
13071386
\`\`\`
13081387

src/endpoints/inference/cloudflare/chat.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { AIEndpoint } from "../../base";
88
import type { AppContext, UsageRecord } from "../../../types";
99
import type { ContentfulStatusCode } from "hono/utils/http-status";
10+
import { response402, tokenTypeParam } from "../../schema";
1011

1112
interface CloudflareAIErrorClassification {
1213
message: string;
@@ -132,19 +133,7 @@ export class CloudflareChat extends AIEndpoint {
132133
},
133134
},
134135
},
135-
parameters: [
136-
{
137-
name: "tokenType",
138-
in: "query" as const,
139-
required: false,
140-
schema: {
141-
type: "string" as const,
142-
enum: ["STX", "sBTC", "USDCx"],
143-
default: "STX",
144-
},
145-
description: "Payment token type",
146-
},
147-
],
136+
parameters: [tokenTypeParam],
148137
responses: {
149138
"200": {
150139
description: "Chat completion response",
@@ -169,7 +158,7 @@ export class CloudflareChat extends AIEndpoint {
169158
},
170159
},
171160
"400": { description: "Invalid request" },
172-
"402": { description: "Payment required" },
161+
"402": response402,
173162
"404": { description: "Model not found (error_code: MODEL_NOT_FOUND, retryable: false)" },
174163
"429": { description: "Rate limit exceeded (error_code: RATE_LIMIT, retryable: true)" },
175164
"502": { description: "Upstream AI error (error_code: INTERNAL_ERROR, retryable: false)" },

src/endpoints/inference/openrouter/chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { BaseEndpoint } from "../../base";
99
import { OpenRouterClient, OpenRouterError } from "../../../services/openrouter";
1010
import { logPnL } from "../../../services/pricing";
1111
import { lookupModel, getSimilarModels } from "../../../services/model-cache";
12-
import { tokenTypeParam } from "../../schema";
12+
import { response402, tokenTypeParam } from "../../schema";
1313
import type { AppContext, ChatCompletionRequest, UsageRecord } from "../../../types";
1414

1515
export class OpenRouterChat extends BaseEndpoint {
@@ -105,7 +105,7 @@ export class OpenRouterChat extends BaseEndpoint {
105105
},
106106
},
107107
"400": { description: "Invalid request" },
108-
"402": { description: "Payment required" },
108+
"402": response402,
109109
"500": { description: "Server error" },
110110
},
111111
};

0 commit comments

Comments
 (0)