import { describe, expect, it } from "vitest";
import generatedRegistry from "../../generated/route-registry.json" assert { type: "json" };
import type { RouteRegistryEntry } from "../../registry/schema";
import {
  canonicalX402Payment,
  decodeX402Payment,
  X402ClientV3,
  type X402Challenge,
  type X402Payment,
} from "../../sdk/v3/x402";
import {
  DeterministicClock,
  InMemoryX402SandboxStore,
  X402MiddlewareSandbox,
  deterministicSigner,
  materializePath,
  paymentHeader,
} from "./x402-sandbox";

const routes = generatedRegistry.routes as unknown as RouteRegistryEntry[];
const priced = routes.filter((route) => !route.free && route.price);
const readRoute = priced.find((route) => route.method === "GET")!;
const anotherReadRoute = priced.find((route) => route.method === "GET" && route.id !== readRoute.id)!;
const requiredIdempotencyRoute = priced.find((route) => route.method === "POST")!;

const payer = deterministicSigner(7);
const attacker = deterministicSigner(9);

function paymentConfig() {
  return {
    payer: payer.payer,
    assetPerChain: { 8453: readRoute.price!.asset },
    payeePerChain: { 8453: readRoute.price!.payee },
  };
}

function setup(validAfterDelayMs = 0, store = new InMemoryX402SandboxStore()) {
  const clock = new DeterministicClock();
  const sandbox = new X402MiddlewareSandbox(
    routes,
    clock,
    new Set([payer.payer]),
    store,
    { validAfterDelayMs },
  );
  return { clock, sandbox, store };
}

async function issueChallenge(
  sandbox: X402MiddlewareSandbox,
  route: RouteRegistryEntry,
  idempotencyKey?: string,
): Promise<{ challenge: X402Challenge; path: string; headers: Headers }> {
  const path = materializePath(route.path);
  const headers = new Headers();
  if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
  const challenged = sandbox.handle({ method: route.method, path, headers });
  expect(challenged.status).toBe(402);
  return { challenge: await sandbox.challengeFrom(challenged), path, headers };
}

function paidHeaders(
  sandbox: X402MiddlewareSandbox,
  challenge: X402Challenge,
  idempotencyKey?: string,
  paymentId?: string,
): Headers {
  const headers = paymentHeader(sandbox.paymentFor(challenge, payer, paymentId));
  if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
  return headers;
}

function signedMutation(
  sandbox: X402MiddlewareSandbox,
  challenge: X402Challenge,
  changes: Partial<X402Payment>,
): Headers {
  const payment = { ...sandbox.paymentFor(challenge, payer, "mutated-payment"), ...changes };
  const { signature: _signature, ...unsigned } = payment;
  payment.signature = payer.sign(canonicalX402Payment(unsigned)) as string;
  return paymentHeader(payment);
}

function clientFor(sandbox: X402MiddlewareSandbox, delays: number[] = []) {
  return new X402ClientV3(
    {
      baseUrl: "https://sandbox.invalid",
      defaultChainId: 8453,
      rpcUrls: {},
      x402: paymentConfig(),
    },
    {
      signer: payer,
      fetch: sandbox.fetch.bind(sandbox),
      sleep: async (milliseconds) => { delays.push(milliseconds); },
      now: () => 1_800_000_000_000,
    },
  );
}

describe("x402 payment security — deterministic middleware sandbox", () => {
  it("challenges and settles every priced generated route with its exact amount, asset, network, and payee", async () => {
    expect(priced).toHaveLength(76);
    const { sandbox } = setup();

    for (const route of priced) {
      const idempotencyKey = (route.idempotencyRule === "key-required" || route.id === requiredIdempotencyRoute.id) ? `all-routes-${route.id}` : undefined;
      const { challenge, path } = await issueChallenge(sandbox, route, idempotencyKey);
      expect(challenge).toMatchObject({
        x402Version: route.price!.x402Version,
        method: route.method,
        path,
        amount: route.price!.amount,
        asset: route.price!.asset,
        network: route.price!.network,
        payee: route.price!.payee,
        finality: route.finality,
      });

      const settled = sandbox.handle({
        method: route.method,
        path,
        headers: paidHeaders(sandbox, challenge, idempotencyKey, `all-routes-${route.id}`),
      });
      expect(settled.status).toBe(200);
      const body = await settled.json() as { data: { routeId: string }; receipt: Record<string, unknown> };
      expect(body.data.routeId).toBe(route.id);
      expect(body.receipt).toMatchObject({
        amount: route.price!.amount,
        asset: route.price!.asset,
        payee: route.price!.payee,
        x402Version: route.price!.x402Version,
      });
    }
    expect(sandbox.facilitator.settled.size).toBe(priced.length);
  });

  it("rejects a wrong signer and an invalid Ed25519 signature before settlement", async () => {
    const { sandbox } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    const wrongSigner = paymentHeader(sandbox.paymentFor(challenge, attacker, "wrong-signer"));
    expect(sandbox.handle({ method: readRoute.method, path, headers: wrongSigner }).status).toBe(401);

    const malformed = sandbox.paymentFor(challenge, payer, "bad-signature");
    malformed.signature = "AAAA";
    expect(sandbox.handle({
      method: readRoute.method,
      path,
      headers: paymentHeader(malformed),
    }).status).toBe(401);
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects an expired signed payment before settlement", async () => {
    const { clock, sandbox } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    clock.advance(60_000);
    const expired = sandbox.handle({
      method: readRoute.method,
      path,
      headers: paidHeaders(sandbox, challenge),
    });
    expect(expired.status).toBe(400);
    expect(await expired.json()).toMatchObject({ code: "expired_payment" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects a correctly signed payment before its validAfter time", async () => {
    const { clock, sandbox } = setup(5_000);
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    expect(challenge.validAfter).toBe(clock.now() + 5_000);

    const early = sandbox.handle({
      method: readRoute.method,
      path,
      headers: paidHeaders(sandbox, challenge),
    });
    expect(early.status).toBe(400);
    expect(await early.json()).toMatchObject({ code: "payment_not_yet_valid" });
    expect(sandbox.facilitator.settled.size).toBe(0);

    clock.advance(5_000);
    expect(sandbox.handle({
      method: readRoute.method,
      path,
      headers: paidHeaders(sandbox, challenge),
    }).status).toBe(200);
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("rejects a persisted challenge with an invalid validity window before settlement", async () => {
    const { sandbox, store } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    const invalidChallenge = { ...challenge, validAfter: challenge.expiresAt };
    const stored = store.getChallenge(challenge.requestId)!;
    store.saveChallenge({ ...stored, validAfter: invalidChallenge.validAfter });

    const result = sandbox.handle({
      method: readRoute.method,
      path,
      headers: paidHeaders(sandbox, invalidChallenge),
    });
    expect(result.status).toBe(400);
    expect(await result.json()).toMatchObject({ code: "invalid_payment_window" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it.each([
    ["underpayment", { amount: "1" }],
    ["overpayment", { amount: "999999999" }],
    ["asset", { asset: "0x0000000000000000000000000000000000000001" }],
    ["network", { network: "polygon" }],
    ["payee", { payee: "0x0000000000000000000000000000000000000002" }],
    ["validAfter", { validAfter: 1 }],
    ["x402 version", { x402Version: 1 as 2 }],
  ])("rejects a correctly signed payment with the wrong %s", async (_field, changes) => {
    const { sandbox } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    const result = sandbox.handle({
      method: readRoute.method,
      path,
      headers: signedMutation(sandbox, challenge, changes),
    });
    expect(result.status).toBe(400);
    expect(await result.json()).toMatchObject({
      code: _field === "x402 version" ? "malformed_payment" : "payment_request_binding_mismatch",
    });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects a validAfter mutation that was not covered by the original signature", async () => {
    const { sandbox } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    const payment = sandbox.paymentFor(challenge, payer, "valid-after-tamper");
    payment.validAfter += 1;

    const result = sandbox.handle({
      method: readRoute.method,
      path,
      headers: paymentHeader(payment),
    });
    expect(result.status).toBe(401);
    expect(await result.json()).toMatchObject({ code: "invalid_signature" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects a malformed payment header without settlement", async () => {
    const { sandbox } = setup();
    const { path } = await issueChallenge(sandbox, readRoute);
    const result = sandbox.handle({
      method: readRoute.method,
      path,
      headers: new Headers({ "X-Payment": "not-base64-json" }),
    });
    expect(result.status).toBe(400);
    expect(await result.json()).toMatchObject({ code: "malformed_payment" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects a signed non-scalar paymentId before Map-based replay protection", async () => {
    const { sandbox } = setup();
    const { challenge, path } = await issueChallenge(sandbox, readRoute);
    const payment = sandbox.paymentFor(challenge, payer, "scalar-payment-id");
    payment.paymentId = [] as unknown as string;
    const { signature: _signature, ...unsigned } = payment;
    payment.signature = payer.sign(canonicalX402Payment(unsigned)) as string;

    const result = sandbox.handle({
      method: readRoute.method,
      path,
      headers: paymentHeader(payment),
    });
    expect(result.status).toBe(400);
    expect(await result.json()).toMatchObject({ code: "malformed_payment" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("rejects replay of a settled payment onto a distinct request without another charge", async () => {
    const { sandbox } = setup();
    const first = await issueChallenge(sandbox, readRoute);
    const headers = paidHeaders(sandbox, first.challenge, undefined, "replay-payment");
    expect(sandbox.handle({ method: readRoute.method, path: first.path, headers }).status).toBe(200);

    const replayed = sandbox.handle({
      method: anotherReadRoute.method,
      path: materializePath(anotherReadRoute.path),
      headers,
    });
    expect(replayed.status).toBe(400);
    expect(await replayed.json()).toMatchObject({ code: "payment_request_binding_mismatch" });
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("rejects a second signed request that reuses a settled payment identity", async () => {
    const { sandbox } = setup();
    const first = await issueChallenge(sandbox, readRoute);
    expect(sandbox.handle({
      method: readRoute.method,
      path: first.path,
      headers: paidHeaders(sandbox, first.challenge, undefined, "single-use-payment"),
    }).status).toBe(200);

    const second = await issueChallenge(sandbox, readRoute);
    const reused = sandbox.handle({
      method: readRoute.method,
      path: second.path,
      headers: paidHeaders(sandbox, second.challenge, undefined, "single-use-payment"),
    });
    expect(reused.status).toBe(409);
    expect(await reused.json()).toMatchObject({ code: "payment_id_reused" });
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("binds the signed method and path so a payment cannot settle another endpoint", async () => {
    const { sandbox } = setup();
    const first = await issueChallenge(sandbox, readRoute);
    const boundElsewhere = sandbox.handle({
      method: anotherReadRoute.method,
      path: materializePath(anotherReadRoute.path),
      headers: paidHeaders(sandbox, first.challenge),
    });
    expect(boundElsewhere.status).toBe(400);
    expect(await boundElsewhere.json()).toMatchObject({ code: "payment_request_binding_mismatch" });
    expect(sandbox.facilitator.settled.size).toBe(0);
  });

  it("does not double-fulfill a delivery retry of the same signed request", async () => {
    const { sandbox } = setup();
    const request = await issueChallenge(sandbox, readRoute);
    const headers = paidHeaders(sandbox, request.challenge, undefined, "one-fulfillment");
    const first = sandbox.handle({ method: readRoute.method, path: request.path, headers });
    const second = sandbox.handle({ method: readRoute.method, path: request.path, headers });
    expect(first.status).toBe(200);
    expect(second.status).toBe(200);
    expect(await second.json()).toEqual(await first.clone().json());
    expect(sandbox.store.fulfillmentCount(request.challenge.requestId)).toBe(1);
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("shares settled fulfillment state across a replacement middleware instance", async () => {
    const { clock, sandbox: first, store } = setup();
    const request = await issueChallenge(first, readRoute);
    const headers = paidHeaders(first, request.challenge, undefined, "restart-retry-payment");
    const settled = first.handle({ method: readRoute.method, path: request.path, headers });
    expect(settled.status).toBe(200);

    const replacement = new X402MiddlewareSandbox(
      routes,
      clock,
      new Set([payer.payer]),
      store,
    );
    const retried = replacement.handle({ method: readRoute.method, path: request.path, headers });
    expect(retried.status).toBe(200);
    expect(await retried.json()).toEqual(await settled.clone().json());
    expect(replacement.facilitator.settled.size).toBe(1);
    expect(store.fulfillmentCount(request.challenge.requestId)).toBe(1);
  });

  it("returns a shared idempotency result from a replacement middleware instance", async () => {
    const { clock, sandbox: first, store } = setup();
    const key = "restart-idempotency-key";
    const request = await issueChallenge(first, requiredIdempotencyRoute, key);
    const settled = first.handle({
      method: requiredIdempotencyRoute.method,
      path: request.path,
      headers: paidHeaders(first, request.challenge, key, "restart-idempotency-payment"),
    });
    expect(settled.status).toBe(200);

    const replacement = new X402MiddlewareSandbox(
      routes,
      clock,
      new Set([payer.payer]),
      store,
    );
    const retried = replacement.handle({
      method: requiredIdempotencyRoute.method,
      path: request.path,
      headers: new Headers({ "Idempotency-Key": key }),
    });
    expect(retried.status).toBe(200);
    expect(await retried.json()).toEqual(await settled.clone().json());
    expect(replacement.facilitator.settled.size).toBe(1);
  });

  it("uses the store's in-process settlement claim to reject a cross-instance payment-id conflict", async () => {
    const { clock, sandbox: first, store } = setup();
    const second = new X402MiddlewareSandbox(routes, clock, new Set([payer.payer]), store);
    const firstRequest = await issueChallenge(first, readRoute);
    const secondRequest = await issueChallenge(second, readRoute);
    const paymentId = "cross-instance-unique-payment";

    expect(first.handle({
      method: readRoute.method,
      path: firstRequest.path,
      headers: paidHeaders(first, firstRequest.challenge, undefined, paymentId),
    }).status).toBe(200);
    const conflict = second.handle({
      method: readRoute.method,
      path: secondRequest.path,
      headers: paidHeaders(second, secondRequest.challenge, undefined, paymentId),
    });
    expect(conflict.status).toBe(409);
    expect(await conflict.json()).toMatchObject({ code: "payment_id_reused" });
    expect(store.settledPayments().size).toBe(1);
  });

  it("uses Idempotency-Key to return a cached result with no second charge", async () => {
    const { sandbox } = setup();
    const key = "same-logical-write";
    const request = await issueChallenge(sandbox, requiredIdempotencyRoute, key);
    const first = sandbox.handle({
      method: requiredIdempotencyRoute.method,
      path: request.path,
      headers: paidHeaders(sandbox, request.challenge, key, "idempotency-payment"),
    });
    expect(first.status).toBe(200);
    const second = sandbox.handle({
      method: requiredIdempotencyRoute.method,
      path: request.path,
      headers: new Headers({ "Idempotency-Key": key }),
    });
    expect(second.status).toBe(200);
    expect(await second.json()).toEqual(await first.clone().json());
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("gates fulfillment on the route's required settlement finality", async () => {
    const { sandbox } = setup();
    const request = await issueChallenge(sandbox, readRoute);
    const headers = paidHeaders(sandbox, request.challenge, undefined, "finality-payment");
    sandbox.facilitator.currentFinality = "soft";
    const early = sandbox.handle({ method: readRoute.method, path: request.path, headers });
    expect(early.status).toBe(425);
    expect(await early.json()).toMatchObject({ code: "finality_not_reached" });
    expect(sandbox.facilitator.settled.size).toBe(0);

    sandbox.facilitator.currentFinality = "safe";
    expect(sandbox.handle({ method: readRoute.method, path: request.path, headers }).status).toBe(200);
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("retries a facilitator timeout with the same payment and creates no phantom charge", async () => {
    const { sandbox } = setup();
    const delays: number[] = [];
    sandbox.facilitator.timeoutNext = true;
    const result = await clientFor(sandbox, delays).pricedFetch<{ data: { routeId: string } }>(
      materializePath(readRoute.path),
      { method: readRoute.method },
    );
    expect(result.data).toMatchObject({ data: { routeId: readRoute.id } });
    expect(delays).toEqual([10]);
    expect(sandbox.facilitator.settleAttempts).toBe(2);
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("returns the same receipt when settlement succeeds but HTTP delivery times out", async () => {
    const { sandbox } = setup();
    const delays: number[] = [];
    sandbox.dropNextSuccessfulResponse = true;
    const result = await clientFor(sandbox, delays).pricedFetch(
      materializePath(readRoute.path),
      { method: readRoute.method },
    );
    expect(result.receipt).toMatchObject({
      receiptId: "receipt-000001",
      txHash: expect.stringMatching(/^0x[0-9a-f]{64}$/),
    });
    expect(delays).toEqual([10]);
    expect(sandbox.facilitator.settleAttempts).toBe(1);
    expect(sandbox.facilitator.settled.size).toBe(1);
  });

  it("returns a complete receipt with txHash, block, chain, payment identity, and finality fields", async () => {
    const { sandbox } = setup();
    const request = await issueChallenge(sandbox, readRoute);
    const paid = sandbox.handle({
      method: readRoute.method,
      path: request.path,
      headers: paidHeaders(sandbox, request.challenge),
    });
    const { receipt } = await paid.json() as { receipt: Record<string, unknown> };
    expect(receipt).toMatchObject({
      receiptId: expect.any(String),
      txHash: expect.stringMatching(/^0x[0-9a-f]{64}$/),
      blockNumber: expect.any(Number),
      chainId: expect.any(Number),
      paymentId: expect.any(String),
      requestId: request.challenge.requestId,
      method: readRoute.method,
      path: request.path,
      network: readRoute.price!.network,
      asset: readRoute.price!.asset,
      amount: readRoute.price!.amount,
      payer: payer.payer,
      payee: readRoute.price!.payee,
      x402Version: readRoute.price!.x402Version,
      finality: readRoute.finality,
    });
  });

  it("rejects a receipt substituted from another signed payment", async () => {
    const challenge: X402Challenge = {
      x402Version: 2,
      requestId: "receipt-binding-request",
      method: readRoute.method,
      path: materializePath(readRoute.path),
      amount: readRoute.price!.amount,
      asset: readRoute.price!.asset,
      network: readRoute.price!.network,
      payee: readRoute.price!.payee,
      validAfter: 1_800_000_000_000,
      expiresAt: 1_800_000_060_000,
      finality: readRoute.finality,
    };
    let calls = 0;
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: payer,
        now: () => 1_800_000_000_000,
        fetch: async () => {
          calls += 1;
          if (calls === 1) {
            return new Response(JSON.stringify({ x402: challenge }), {
              status: 402,
              headers: { "content-type": "application/json" },
            });
          }
          return new Response(JSON.stringify({
            data: { ok: true },
            receipt: {
              receiptId: "receipt-substituted",
              txHash: `0x${"1".repeat(64)}`,
              blockNumber: 1,
              chainId: 8453,
              paymentId: "other-payment",
              requestId: challenge.requestId,
              method: challenge.method,
              path: challenge.path,
              network: challenge.network,
              asset: challenge.asset,
              amount: challenge.amount,
              payer: payer.payer,
              payee: challenge.payee,
              x402Version: 2,
              finality: challenge.finality,
            },
          }), {
            status: 200,
            headers: { "content-type": "application/json" },
          });
        },
      },
    );

    await expect(client.pricedFetch(challenge.path, { method: challenge.method }))
      .rejects.toThrow("receipt is not bound to the submitted payment");
  });

  it.each([
    ["another resource", { path: "/api/x402/not-the-requested-resource" }, "requested method and resource"],
    ["another method", { method: "POST" }, "requested method and resource"],
    ["an expired validity window", { expiresAt: 1_800_000_000_000 }, "expired or invalid"],
    ["a future not-before time", { validAfter: 1_800_000_000_001 }, "expired or invalid"],
    ["an inverted validity window", { validAfter: 1_800_000_060_000 }, "expired or invalid"],
  ])("rejects a challenge for %s before signing or retrying", async (_label, mutation, error) => {
    const challenge: X402Challenge = {
      x402Version: 2,
      requestId: "reject-unbound-challenge",
      method: readRoute.method,
      path: materializePath(readRoute.path),
      amount: readRoute.price!.amount,
      asset: readRoute.price!.asset,
      network: readRoute.price!.network,
      payee: readRoute.price!.payee,
      validAfter: 1_800_000_000_000,
      expiresAt: 1_800_000_060_000,
      finality: readRoute.finality,
      ...mutation,
    };
    let calls = 0;
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: payer,
        now: () => 1_800_000_000_000,
        fetch: async () => {
          calls += 1;
          return new Response(JSON.stringify({ x402: challenge }), { status: 402 });
        },
      },
    );
    await expect(client.pricedFetch(materializePath(readRoute.path), { method: readRoute.method }))
      .rejects.toThrow(error);
    expect(calls).toBe(1);
  });

  it.each([
    ["a non-canonical atomic amount", { amount: "00010000" }, "malformed x402 payment fields"],
    ["a negative atomic amount", { amount: "-1" }, "malformed x402 payment fields"],
    ["a non-EVM payment rail", { network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" }, "unsupported payment network"],
    ["an unexpected payment payee", { payee: "0x0000000000000000000000000000000000000002" }, "unexpected payment payee"],
  ])("rejects %s before it can sign a payment", async (_label, mutation, error) => {
    const challenge: X402Challenge = {
      x402Version: 2,
      requestId: "reject-malformed-challenge",
      method: readRoute.method,
      path: materializePath(readRoute.path),
      amount: readRoute.price!.amount,
      asset: readRoute.price!.asset,
      network: readRoute.price!.network,
      payee: readRoute.price!.payee,
      validAfter: 1_800_000_000_000,
      expiresAt: 1_800_000_060_000,
      finality: readRoute.finality,
      ...mutation,
    };
    let calls = 0;
    let signatures = 0;
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: { payer: payer.payer, sign: (message) => { signatures += 1; return payer.sign(message); } },
        now: () => 1_800_000_000_000,
        fetch: async () => {
          calls += 1;
          return new Response(JSON.stringify({ x402: challenge }), { status: 402 });
        },
      },
    );
    await expect(client.pricedFetch(challenge.path, { method: challenge.method })).rejects.toThrow(error);
    expect(calls).toBe(1);
    expect(signatures).toBe(0);
  });

  it("does not retry a terminal paid 4xx response", async () => {
    const challenge: X402Challenge = {
      x402Version: 2,
      requestId: "terminal-paid-response",
      method: readRoute.method,
      path: materializePath(readRoute.path),
      amount: readRoute.price!.amount,
      asset: readRoute.price!.asset,
      network: readRoute.price!.network,
      payee: readRoute.price!.payee,
      validAfter: 1_800_000_000_000,
      expiresAt: 1_800_000_060_000,
      finality: readRoute.finality,
    };
    let calls = 0;
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: payer,
        now: () => 1_800_000_000_000,
        fetch: async () => {
          calls += 1;
          return calls === 1
            ? new Response(JSON.stringify({ x402: challenge }), { status: 402 })
            : new Response(JSON.stringify({ code: "payment_request_binding_mismatch" }), { status: 400 });
        },
      },
    );
    await expect(client.pricedFetch(challenge.path, { method: challenge.method })).rejects.toMatchObject({ status: 400 });
    expect(calls).toBe(2);
  });

  it("fails closed when a priced request receives an unbound initial success", async () => {
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: payer,
        fetch: async () => new Response(JSON.stringify({ data: { ok: true } }), { status: 200 }),
      },
    );
    await expect(client.pricedFetch(materializePath(readRoute.path), { method: readRoute.method }))
      .rejects.toThrow("success without a payment challenge");
  });

  it("rejects a receipt that names a chain other than the configured payment chain", async () => {
    const challenge: X402Challenge = {
      x402Version: 2,
      requestId: "chain-binding-request",
      method: readRoute.method,
      path: materializePath(readRoute.path),
      amount: readRoute.price!.amount,
      asset: readRoute.price!.asset,
      network: readRoute.price!.network,
      payee: readRoute.price!.payee,
      validAfter: 1_800_000_000_000,
      expiresAt: 1_800_000_060_000,
      finality: readRoute.finality,
    };
    let calls = 0;
    const client = new X402ClientV3(
      {
        baseUrl: "https://sandbox.invalid",
        defaultChainId: 8453,
        rpcUrls: {},
        x402: paymentConfig(),
      },
      {
        signer: payer,
        now: () => 1_800_000_000_000,
        fetch: async (_input, init) => {
          calls += 1;
          if (calls === 1) return new Response(JSON.stringify({ x402: challenge }), { status: 402 });
          const encoded = new Headers(init?.headers).get("X-Payment");
          const payment = decodeX402Payment(encoded!);
          return new Response(JSON.stringify({
            data: { ok: true },
            receipt: {
              receiptId: "receipt-wrong-chain",
              txHash: `0x${"2".repeat(64)}`,
              blockNumber: 1,
              chainId: 1,
              paymentId: payment.paymentId,
              requestId: challenge.requestId,
              method: challenge.method,
              path: challenge.path,
              network: challenge.network,
              asset: challenge.asset,
              amount: challenge.amount,
              payer: payer.payer,
              payee: challenge.payee,
              x402Version: 2,
              finality: challenge.finality,
            },
          }), { status: 200, headers: { "content-type": "application/json" } });
        },
      },
    );
    await expect(client.pricedFetch(challenge.path, { method: challenge.method }))
      .rejects.toThrow("receipt is not bound to the submitted payment");
  });
});
