From e0a32e564fa21052cc4a30a496ac01c5cc6daa25 Mon Sep 17 00:00:00 2001 From: Trevor Miller Date: Fri, 15 May 2026 14:57:47 -0400 Subject: [PATCH] =?UTF-8?q?sdk:=20round-4=20CLI=20follow-ups=20=E2=80=94?= =?UTF-8?q?=20PM=20intent=20tokenAmount=20guard=20+=20resolveExpiration/co?= =?UTF-8?q?llection-options=20specs=20(#0431-#0433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0431 canonical buildPredictionMarket{Buy,Sell}Intent now throw on non-positive tokenAmount — closes the asymmetry where `bb build pm-*-intent` rejected amount≤0 but `bb prediction-markets buy/sell` (calling the canonical fn directly) silently accepted it. Plus isUserRecurringApproval charge-period cap is now bigint-exact (was a lossy Number() round-trip; matches the producer userRecurringApproval). 0432 direct resolveExpiration unit spec — the duration branch value was only tested indirectly (the ms-since-epoch + default branches were); now all three branches + the ≥10-digit ms heuristic boundary + invalid-input are asserted. 0433 collection-options.spec.ts — normalizeCollection (unwrap / never-throw / raw-fallback) + validateCollectionOrExit (not-found / errors+warnings / BB_QUIET gate / silent-valid). Closes the #0429 round-2 spec miss. Build clean (no circular deps); unit 143 suites / 3108 tests; integration 20 suites / 186 tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/cli/utils/collection-options.spec.ts | 91 +++++++++++++++++++ .../src/core/builders/builders.spec.ts | 50 +++++++++- .../src/core/prediction-markets.ts | 6 ++ .../bitbadgesjs-sdk/src/core/subscriptions.ts | 5 +- 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts new file mode 100644 index 0000000000..b44a089b36 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts @@ -0,0 +1,91 @@ +/** + * collection-options.ts coverage (ticket 0433) — shipped in #0429 + * without its spec (a round-2 implementation miss caught by the + * round-3 audit). Both helpers carry the branching logic whose absence + * was the latent boundary bug #0429 fixed; sibling csv-options.ts from + * the same DRY cycle is fully specced — this closes the gap. + */ + +import { normalizeCollection, validateCollectionOrExit } from './collection-options.js'; + +describe('normalizeCollection', () => { + // 0429's actual fix: the 4 raw command copies returned the indexer + // envelope unmodified; normalizeCollection must unwrap + run the + // class normalization (BigIntify of numeric fields is the + // BitBadgesCollection class's own, separately-tested concern — here + // we pin the boundary contract: unwrap, never-throw, raw fallback). + it('unwraps a {collection: X} envelope (returns the inner object, not the wrapper)', () => { + const out = normalizeCollection({ collection: { collectionId: '5', foo: 'bar' } }); + expect(out).not.toHaveProperty('collection'); + expect(out.collectionId).toBe('5'); + }); + it('processes a bare collection (no envelope) directly', () => { + const out = normalizeCollection({ collectionId: '7' }); + expect(out).toBeTruthy(); + expect(out.collectionId).toBe('7'); + }); + it('passes null / undefined through untouched', () => { + expect(normalizeCollection(null)).toBeNull(); + expect(normalizeCollection(undefined)).toBeUndefined(); + }); + it('never throws — falls back to raw when class construction fails', () => { + const weird: any = 'not-a-collection'; + expect(normalizeCollection(weird)).toBe('not-a-collection'); + const broken: any = { collection: 12345 }; + expect(() => normalizeCollection(broken)).not.toThrow(); + }); +}); + +describe('validateCollectionOrExit', () => { + let exitSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation(((_c?: number) => { + throw new Error('process.exit'); + }) as never); + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + delete process.env.BB_QUIET; + }); + + const ok = () => ({ valid: true, errors: [], warnings: [] }); + + it('exits 2 with a not-found message when collection is missing', () => { + expect(() => validateCollectionOrExit(null, 'ctx-x', ok, 'Bounty')).toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderrSpy.mock.calls.map((c) => c[0]).join('')).toContain('collection not found while running ctx-x'); + }); + + it('prints errors + warnings and exits 2 when invalid', () => { + const bad = () => ({ valid: false, errors: ['e1', 'e2'], warnings: ['w1'] }); + expect(() => validateCollectionOrExit({}, 'mint', bad, 'Crowdfund')).toThrow('process.exit'); + const txt = stderrSpy.mock.calls.map((c) => c[0]).join(''); + expect(txt).toContain('not a valid Crowdfund (failed in mint)'); + expect(txt).toContain('- e1'); + expect(txt).toContain('- e2'); + expect(txt).toContain('- w1'); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + + it('valid + warnings → echoes warnings unless BB_QUIET, never exits', () => { + const warn = () => ({ valid: true, errors: [], warnings: ['heads-up'] }); + validateCollectionOrExit({}, 'claim', warn, 'Auction'); + expect(exitSpy).not.toHaveBeenCalled(); + expect(stderrSpy.mock.calls.map((c) => c[0]).join('')).toContain('Warnings for claim'); + + stderrSpy.mockClear(); + process.env.BB_QUIET = '1'; + validateCollectionOrExit({}, 'claim', warn, 'Auction'); + expect(stderrSpy.mock.calls.map((c) => c[0]).join('')).not.toContain('Warnings for claim'); + }); + + it('valid + no warnings → silent, no exit', () => { + validateCollectionOrExit({}, 'ctx', ok, 'Smart Token'); + expect(exitSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts index 354b081339..1111cb0619 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts @@ -29,9 +29,10 @@ import { buildListing } from './listing.js'; import { buildBid } from './bid.js'; import { buildPmSellIntent } from './pm-sell-intent.js'; import { buildPmBuyIntent } from './pm-buy-intent.js'; -import { resolveCoin, parseDuration, toBaseUnits, sanitizeCosmosPathName } from './shared.js'; +import { resolveCoin, parseDuration, toBaseUnits, sanitizeCosmosPathName, resolveExpiration } from './shared.js'; import { buildPredictionMarketBuyIntent, buildPredictionMarketSellIntent } from '../prediction-markets.js'; import { buildIntentApproval } from '../intents.js'; +import { UintRangeArray } from '../uintRanges.js'; import { buildOrderbookBidApproval, buildOrderbookListingApproval } from '../bids.js'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -130,6 +131,32 @@ describe('shared utilities', () => { expect(() => parseDuration('invalid')).toThrow('Invalid duration'); }); + describe('resolveExpiration (#0432 — direct spec; the duration branch was only tested indirectly)', () => { + test('empty/undefined → now + defaultMs', () => { + const before = Date.now(); + const got = Number(resolveExpiration(undefined, 30 * 24 * 60 * 60 * 1000)); + expect(got).toBeGreaterThanOrEqual(before + 30 * 24 * 60 * 60 * 1000 - 50); + expect(got).toBeLessThanOrEqual(Date.now() + 30 * 24 * 60 * 60 * 1000 + 50); + expect(Number(resolveExpiration('', 1000))).toBeGreaterThan(Date.now()); + }); + test('pure-digit ≥10 chars → raw ms-since-epoch (no now offset)', () => { + expect(resolveExpiration('1798765432000', 1000)).toBe(1798765432000n); + }); + test('duration shorthand → now + parseDuration', () => { + const got = Number(resolveExpiration('30d', 1000)); + const exp = Date.now() + 2592000000; + expect(Math.abs(got - exp)).toBeLessThan(2000); + const h = Number(resolveExpiration('24h', 1000)); + expect(Math.abs(h - (Date.now() + 86400000))).toBeLessThan(2000); + }); + test('a <10-digit numeric string is treated as duration (→ throws), NOT raw ms', () => { + expect(() => resolveExpiration('123456789', 1000)).toThrow('Invalid duration'); + }); + test('unparseable input throws', () => { + expect(() => resolveExpiration('nope', 1000)).toThrow('Invalid duration'); + }); + }); + test('sanitizeCosmosPathName passes clean input through', () => { expect(sanitizeCosmosPathName('vUSDC', 'symbol')).toBe('vUSDC'); expect(sanitizeCosmosPathName('CREDIT', 'symbol')).toBe('CREDIT'); @@ -976,6 +1003,27 @@ describe('pm-buy-intent builder (delegates to canonical)', () => { }); }); +describe('canonical PM intent tokenAmount guard (#0431 — bb prediction-markets buy/sell parity)', () => { + const base = { + address: 'bb1t', collectionId: '9', tokenId: 1n, + paymentDenom: 'uusdc', paymentAmount: 1000n, + transferTimes: UintRangeArray.From([{ start: 1n, end: 9999999999999n }]), + approvalId: 'pm-guard' + }; + test('buildPredictionMarketBuyIntent throws on 0n / negative tokenAmount', () => { + expect(() => buildPredictionMarketBuyIntent({ ...base, tokenAmount: 0n })).toThrow(/positive integer/i); + expect(() => buildPredictionMarketBuyIntent({ ...base, tokenAmount: -5n })).toThrow(/positive integer/i); + }); + test('buildPredictionMarketSellIntent throws on 0n / negative tokenAmount', () => { + expect(() => buildPredictionMarketSellIntent({ ...base, tokenAmount: 0n })).toThrow(/positive integer/i); + expect(() => buildPredictionMarketSellIntent({ ...base, tokenAmount: -1n })).toThrow(/positive integer/i); + }); + test('a positive tokenAmount still builds', () => { + expect(buildPredictionMarketBuyIntent({ ...base, tokenAmount: 200n }).approvalId).toBe('pm-guard'); + expect(buildPredictionMarketSellIntent({ ...base, tokenAmount: 1n }).approvalId).toBe('pm-guard'); + }); +}); + // ── Zero-violations suite: every builder must pass ALL standard checks ─────── describe('all collection builders pass verifyStandardsCompliance with zero violations', () => { diff --git a/packages/bitbadgesjs-sdk/src/core/prediction-markets.ts b/packages/bitbadgesjs-sdk/src/core/prediction-markets.ts index cbfeb4ae7d..2be826d8a0 100644 --- a/packages/bitbadgesjs-sdk/src/core/prediction-markets.ts +++ b/packages/bitbadgesjs-sdk/src/core/prediction-markets.ts @@ -844,6 +844,9 @@ export interface PredictionMarketSideArgs { * is misleading — it's an incoming approval, posted via MsgSetIncomingApproval). */ export function buildPredictionMarketBuyIntent(args: PredictionMarketSideArgs): Record { + if (args.tokenAmount <= 0n) { + throw new Error(`Prediction market intent tokenAmount must be a positive integer (got ${args.tokenAmount}).`); + } const tokenIds = [{ start: args.tokenId, end: args.tokenId }]; return { fromListId: 'All', @@ -923,6 +926,9 @@ export function buildPredictionMarketBuyIntent(args: PredictionMarketSideArgs): * receive `paymentAmount` of `paymentDenom`. Proto-shape only. */ export function buildPredictionMarketSellIntent(args: PredictionMarketSideArgs): Record { + if (args.tokenAmount <= 0n) { + throw new Error(`Prediction market intent tokenAmount must be a positive integer (got ${args.tokenAmount}).`); + } const tokenIds = [{ start: args.tokenId, end: args.tokenId }]; return { toListId: 'All', diff --git a/packages/bitbadgesjs-sdk/src/core/subscriptions.ts b/packages/bitbadgesjs-sdk/src/core/subscriptions.ts index 36b0f8f52c..a7fc18755a 100644 --- a/packages/bitbadgesjs-sdk/src/core/subscriptions.ts +++ b/packages/bitbadgesjs-sdk/src/core/subscriptions.ts @@ -214,7 +214,10 @@ export const isUserRecurringApproval = (approval: iUserIncomingApproval, } const intervalLength = BigInt(subscriptionApproval.approvalCriteria?.predeterminedBalances?.incrementedBalances.durationFromTimestamp ?? 0); - const chargePeriodLength = BigInt(Math.min(Number(intervalLength), 604800000)); + // Bigint-exact cap (matches the producer at userRecurringApproval); the + // old `Number(intervalLength)` round-trip was lossy for absurd intervals. + const chargePeriodLength = + intervalLength < RECURRING_CHARGE_PERIOD_CAP_MS ? intervalLength : RECURRING_CHARGE_PERIOD_CAP_MS; const subscriptionAmount = subscriptionApproval.approvalCriteria?.coinTransfers?.[0]?.coins?.[0]?.amount ?? 0n; const approvalAmount = approval.approvalCriteria?.coinTransfers?.[0]?.coins?.[0]?.amount ?? 0n;