Skip to content

Reject non-positive priceCents before building x402 offers #7

Description

@chenshj73

Summary

createGateway() currently accepts any finite priceCents, including 0 and negative values. Those values are then converted into the x402 offer amount, which can produce amount: "0" or a negative amount string.

That leaves the gateway in a confusing enabled-but-unbuyable state: crawlers receive a 402 offer, but the payment path later rejects non-positive per-day amounts before CoinPay is called.

Relevant code

The public option is documented as the daily price:

// src/index.js
/**
 * @param {object} options
 * @param {string} options.siteUrl                 canonical origin, no trailing slash
 * @param {string} [options.siteName]              for the page; defaults to the hostname
 * @param {{apiKey: string, baseUrl?: string}} [options.coinpay]  a SCOPED CoinPay key (cp_live_…) with payments:create
 * @param {string} [options.payTo]                 EVM address that receives the USDC
 * @param {number} [options.priceCents=100]
 * @param {string} [options.currency='USD']
 * @param {number} [options.passMinutes=1440]          a day: the term one payment buys
 * @param {number} [options.maxDays=30]            the most terms one proof may buy at once
 */
export function createGateway(options = {}) {
  const o = normalise(options);
  const enabled = Boolean(o.coinpay.apiKey && o.payTo);
  const secret = o.secret || o.coinpay.apiKey || null;

  // ...

  const offer = (days = 1) =>
    enabled
      ? buildOffer({
          payTo: o.payTo,
          priceCents: o.priceCents * days,
          resource: buyUrl,
          description: `${days * o.passMinutes} minutes of crawl access to ${o.siteUrl}${days > 1 ? ` (${days} × ${o.passMinutes})` : ''}`,
        })
      : { x402Version: 2, accepts: [] };

The normalizer only checks that priceCents is finite:

// src/index.js
function normalise(options) {
  const siteUrl = String(options.siteUrl ?? '').replace(/\/+$/, '');
  if (!siteUrl) throw new Error('createGateway needs siteUrl');
  const training = options.training ?? TRAINING_AGENTS;
  return {
    siteUrl,
    siteName: options.siteName || new URL(siteUrl).hostname,
    coinpay: {
      apiKey: options.coinpay?.apiKey ?? '',
      baseUrl: (options.coinpay?.baseUrl ?? 'https://coinpayportal.com').replace(/\/+$/, ''),
    },
    payTo: options.payTo ?? '',
    priceCents: Number.isFinite(options.priceCents) ? options.priceCents : 100,
    currency: options.currency ?? 'USD',
    passMinutes: Number.isFinite(options.passMinutes) && options.passMinutes > 0 ? options.passMinutes : 1440,
    maxDays: Number.isInteger(options.maxDays) && options.maxDays >= 1 ? options.maxDays : 30,

The offer amount is built directly from that value:

// src/x402.js
export function buildOffer({
  payTo,
  priceCents,
  resource,
  description = 'Payment required',
  maxTimeoutSeconds = 300,
  methods = METHODS,
}) {
  if (!payTo) throw new Error('an offer needs a payTo address');
  const amount = String(Math.ceil((Number(priceCents) / 100) * 10 ** DECIMALS));
  return {
    x402Version: 2,
    accepts: methods.map((m) => ({
      scheme: 'exact',
      network: m.network,
      amount,
      asset: m.asset,
      payTo,
      resource,

And the later purchase path treats a non-positive per-day amount as invalid:

// src/index.js
export function daysPaid(value, unit, maxDays) {
  if (value === null) return 0;
  let per;
  try {
    per = BigInt(unit);
  } catch {
    return 0;
  }
  if (per <= 0n || value % per !== 0n) return 0;
  const days = value / per;
  if (days < 1n || days > BigInt(maxDays)) return 0;
  return Number(days);
}

The tests already capture the intended invariant that a zero unit price buys nothing:

// test/days.test.js
it('daysPaid', () => {
  assert.equal(daysPaid(1000000n, '1000000', 30), 1);
  assert.equal(daysPaid(30000000n, '1000000', 30), 30);
  assert.equal(daysPaid(31000000n, '1000000', 30), 0);
  assert.equal(daysPaid(1500000n, '1000000', 30), 0);
  assert.equal(daysPaid(0n, '1000000', 30), 0);
  assert.equal(daysPaid(null, '1000000', 30), 0);
  assert.equal(daysPaid(1000000n, '0', 30), 0);
  assert.equal(daysPaid(1000000n, 'x', 30), 0);
});

Why this matters

For example, createGateway({ siteUrl, coinpay, payTo, priceCents: 0 }) still enables the gateway and emits a payment-required response with an offered amount of "0". But a submitted proof cannot buy a pass because the per-day amount is rejected by daysPaid().

For a negative value, the sales page/receipt can also advertise a negative price while the generated x402 offer contains a negative amount, which is not a meaningful token transfer amount.

Suggested fix

Consider validating priceCents during normalization and rejecting non-positive values at startup/config time:

if (!Number.isFinite(options.priceCents) || options.priceCents <= 0) {
  throw new Error('priceCents must be a positive finite number');
}

If sub-cent prices are intentionally supported, the check can remain strictly > 0; the existing rounding-up behavior for tiny positive values such as 0.0001 cents can stay as-is.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions