Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fair-decimals-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@inflowpayai/x402': patch
'@inflowpayai/x402-buyer': patch
'@inflowpayai/x402-seller': patch
---

Normalize server decimal response strings without scientific notation at SDK response boundaries.
4 changes: 2 additions & 2 deletions packages/x402-buyer/src/signer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InflowApiError, InflowHttpClient } from '@inflowpayai/x402';
import { InflowApiError, InflowHttpClient, normalizeDecimalString } from '@inflowpayai/x402';
import type {
InflowPaymentPayload,
PaymentRequirements,
Expand Down Expand Up @@ -114,7 +114,7 @@ export async function createInflowSigner(options: SignerOptions): Promise<Inflow
const out: BuyerLedgerBalance[] = [];
for (const b of list) {
if (typeof b.currency === 'string' && typeof b.available === 'string') {
out.push({ currency: b.currency, available: b.available });
out.push({ currency: b.currency, available: normalizeDecimalString(b.available) });
}
}
return out;
Expand Down
14 changes: 14 additions & 0 deletions packages/x402-buyer/test/unit/inflow-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,20 @@ describe('InflowClient.selectInflowRequirement', () => {
expect(match?.extra?.['assetName']).toBe('USDC');
});

it('normalizes ledger balance decimals before affordability checks', async () => {
installSupported();
server.use(
http.get(`${PROD_BASE}/v1/balances`, () =>
HttpResponse.json({
balances: [{ currency: 'USDC', available: '0.010000000000000000' }],
}),
),
);
const client = await createInflowClient({ apiKey: 'sk_test' });
const match = await client.selectInflowRequirement(paymentRequired([balanceRow('USDC')]));
expect(match?.extra?.['assetName']).toBe('USDC');
});

it('falls back to the first balance entry when balances cannot be read', async () => {
installSupported();
server.use(http.get(`${PROD_BASE}/v1/balances`, () => new HttpResponse(null, { status: 500 })));
Expand Down
11 changes: 9 additions & 2 deletions packages/x402-seller/src/facilitator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InflowApiError, InflowHttpClient, X402_VERSION } from '@inflowpayai/x402';
import { InflowApiError, InflowHttpClient, normalizeDecimalString, X402_VERSION } from '@inflowpayai/x402';
import type {
InflowPaymentPayload,
PaymentRequirements,
Expand Down Expand Up @@ -164,7 +164,7 @@ function buildFacilitator(
}
},
async settle(paymentPayload, paymentRequirements) {
return http.post<SettleResponse>(
const response = await http.post<SettleResponse>(
SETTLE_PATH,
{
x402Version: X402_VERSION,
Expand All @@ -173,11 +173,18 @@ function buildFacilitator(
},
{ retries: 0 },
);
return normalizeSettleResponse(response);
},
};
return asFacilitatorClient(shape);
}

function normalizeSettleResponse(response: SettleResponse): SettleResponse {
if (typeof response.amount !== 'string') return response;
const amount = normalizeDecimalString(response.amount);
return amount === response.amount ? response : { ...response, amount };
}

/**
* Structural check for the body the InFlow facilitator returns alongside a 412 verify response. Defensive — if the
* runtime ever emits a different 412 body shape, the caller sees the original {@link InflowApiError} instead of a
Expand Down
42 changes: 42 additions & 0 deletions packages/x402-seller/test/unit/facilitator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function installDefaultHandlers(counts: CallCounts = { supported: 0, verify: 0,
payer: '0xpayer',
transaction: '0xtxhash',
network: 'eip155:8453',
amount: '0.010000000000000000',
});
}),
);
Expand Down Expand Up @@ -156,6 +157,47 @@ describe('createInflowFacilitator', () => {
);
expect(result.success).toBe(true);
expect(result.network).toBe('eip155:8453');
expect(result.amount).toBe('0.01');
});

it('settle preserves already-normalized amount responses', async () => {
server.use(
http.post(`${PROD_BASE}/v1/x402/settle`, () =>
HttpResponse.json({
success: true,
payer: '0xpayer',
transaction: '0xtxhash',
network: 'eip155:8453',
amount: '0.01',
}),
),
);
const fac = createInflowFacilitator({ environment: 'production', apiKey: 'sk_test' });
const result = await fac.settle(
{
x402Version: 2,
accepted: {
scheme: 'balance',
network: 'inflow:1',
asset: 'USDC',
amount: '1',
payTo: SAMPLE_CONFIG.sellerId,
maxTimeoutSeconds: 300,
extra: {},
},
payload: { transactionId: '00000000-0000-0000-0000-000000000abc' },
},
{
scheme: 'balance',
network: 'inflow:1',
asset: 'USDC',
amount: '1',
payTo: SAMPLE_CONFIG.sellerId,
maxTimeoutSeconds: 300,
extra: {},
},
);
expect(result.amount).toBe('0.01');
});

it('verify auto-embeds a payment-identifier extension entry when absent', async () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/x402/src/decimal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const PLAIN_DECIMAL_RE = /^-?\d+(?:\.\d+)?$/u;

/**
* Normalize a plain decimal string for human/API response display without using `Number`, preserving precision and
* avoiding exponential notation. Non-plain forms are returned unchanged so protocol/canonical wire values are never
* silently rewritten.
*/
export function normalizeDecimalString(value: string): string {
if (!PLAIN_DECIMAL_RE.test(value)) return value;

const negative = value.startsWith('-');
const unsigned = negative ? value.slice(1) : value;
const [rawInteger = '0', rawFraction] = unsigned.split('.');
const integer = stripLeadingZeros(rawInteger);
const fraction = rawFraction?.replace(/0+$/u, '') ?? '';

if (integer === '0' && fraction.length === 0) return '0';
const sign = negative ? '-' : '';
return fraction.length === 0 ? `${sign}${integer}` : `${sign}${integer}.${fraction}`;
}

function stripLeadingZeros(value: string): string {
const stripped = value.replace(/^0+/u, '');
return stripped.length === 0 ? '0' : stripped;
}
2 changes: 2 additions & 0 deletions packages/x402/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type { HeaderBag } from './constants.js';
export { resolveBaseUrl } from './environment.js';
export type { Environment, ResolveBaseUrlOptions } from './environment.js';

export { normalizeDecimalString } from './decimal.js';

export { InflowApiError, X402VersionMismatchError } from './errors.js';
export type { InflowApiErrorInit } from './errors.js';

Expand Down
28 changes: 28 additions & 0 deletions packages/x402/test/unit/decimal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';

import { normalizeDecimalString } from '../../src/decimal.js';

describe('normalizeDecimalString', () => {
it('strips trailing fractional zeros without using exponential notation', () => {
expect(normalizeDecimalString('100.500000000000000000')).toBe('100.5');
expect(normalizeDecimalString('0.010000000000000000')).toBe('0.01');
expect(normalizeDecimalString('0.000001000000000000')).toBe('0.000001');
});

it('collapses integer-valued decimals and negative zero', () => {
expect(normalizeDecimalString('00042')).toBe('42');
expect(normalizeDecimalString('42.000')).toBe('42');
expect(normalizeDecimalString('00042.000')).toBe('42');
expect(normalizeDecimalString('-0.000')).toBe('0');
});

it('preserves the sign on non-zero negative decimals', () => {
expect(normalizeDecimalString('-0012.3400')).toBe('-12.34');
});

it('leaves non-plain decimal strings untouched', () => {
expect(normalizeDecimalString('1e-6')).toBe('1e-6');
expect(normalizeDecimalString('1,000.00')).toBe('1,000.00');
expect(normalizeDecimalString('')).toBe('');
});
});
Loading