profullstack / profullstack/x402-gateway

Reject non-positive priceCents before building x402 offers

Open Beginner friendly
#7 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
0
Forks
0
Avg merge
8m
Merged PRs (30d)
8

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/index.js at normalise(), then trace how priceCents reaches the offer construction shown in src/x402.js. Verify that zero and negative prices are rejected during configuration while valid positive values retain their behavior, and keep the existing cases in test/days.test.js passing.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
api, backend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
84/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.