profullstack / profullstack/x402-gateway
Reject non-positive priceCents before building x402 offers
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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