diff --git a/.changeset/fair-decimals-hammer.md b/.changeset/fair-decimals-hammer.md new file mode 100644 index 0000000..d208e9c --- /dev/null +++ b/.changeset/fair-decimals-hammer.md @@ -0,0 +1,7 @@ +--- +'@inflowpayai/x402': patch +'@inflowpayai/x402-buyer': patch +--- + +Add `normalizeDecimalString` and apply it to buyer ledger balances (`getBalances`), collapsing padded decimal strings +like `0.010000000000000000` to `0.01` for display. Facilitator settle responses are left untouched. diff --git a/packages/x402-buyer/src/signer.ts b/packages/x402-buyer/src/signer.ts index 3da65af..1d79be9 100644 --- a/packages/x402-buyer/src/signer.ts +++ b/packages/x402-buyer/src/signer.ts @@ -1,4 +1,4 @@ -import { InflowApiError, InflowHttpClient } from '@inflowpayai/x402'; +import { InflowApiError, InflowHttpClient, normalizeDecimalString } from '@inflowpayai/x402'; import type { InflowPaymentPayload, PaymentRequirements, @@ -114,7 +114,7 @@ export async function createInflowSigner(options: SignerOptions): Promise { await expect(client.cancelApproval('apr_auth')).rejects.toThrow('auth-fail'); }); }); + +describe('createInflowSigner.getBalances', () => { + it('normalizes ledger balance decimal strings, dropping trailing zeros', async () => { + installSupported(); + server.use( + http.get(`${PROD_BASE}/v1/balances`, () => + HttpResponse.json({ + balances: [ + { currency: 'USDC', available: '0.010000000000000000' }, + { currency: 'PYUSD', available: '89.197620000000000000' }, + { currency: 'USDT', available: '0.000000000000000000' }, + ], + }), + ), + ); + const signer = await createInflowSigner({ apiKey: 'sk_test' }); + expect(await signer.getBalances()).toEqual([ + { currency: 'USDC', available: '0.01' }, + { currency: 'PYUSD', available: '89.19762' }, + { currency: 'USDT', available: '0' }, + ]); + }); +}); diff --git a/packages/x402/src/decimal.ts b/packages/x402/src/decimal.ts new file mode 100644 index 0000000..aff4b53 --- /dev/null +++ b/packages/x402/src/decimal.ts @@ -0,0 +1,25 @@ +const PLAIN_DECIMAL_RE = /^-?\d+(?:\.\d+)?$/u; + +/** + * Collapse a plain decimal string to its shortest exact form by stripping insignificant leading integer zeros and + * trailing fractional zeros. Non-plain forms (exponential notation, thousands separators, empty) are returned + * unchanged. + */ +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; +} diff --git a/packages/x402/src/index.ts b/packages/x402/src/index.ts index ce4c20f..397b3a7 100644 --- a/packages/x402/src/index.ts +++ b/packages/x402/src/index.ts @@ -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'; diff --git a/packages/x402/test/unit/decimal.test.ts b/packages/x402/test/unit/decimal.test.ts new file mode 100644 index 0000000..567be36 --- /dev/null +++ b/packages/x402/test/unit/decimal.test.ts @@ -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(''); + }); +});