Skip to content
Merged
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
91 changes: 91 additions & 0 deletions packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
50 changes: 49 additions & 1 deletion packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/bitbadgesjs-sdk/src/core/prediction-markets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,9 @@ export interface PredictionMarketSideArgs {
* is misleading — it's an incoming approval, posted via MsgSetIncomingApproval).
*/
export function buildPredictionMarketBuyIntent(args: PredictionMarketSideArgs): Record<string, unknown> {
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',
Expand Down Expand Up @@ -923,6 +926,9 @@ export function buildPredictionMarketBuyIntent(args: PredictionMarketSideArgs):
* receive `paymentAmount` of `paymentDenom`. Proto-shape only.
*/
export function buildPredictionMarketSellIntent(args: PredictionMarketSideArgs): Record<string, unknown> {
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',
Expand Down
5 changes: 4 additions & 1 deletion packages/bitbadgesjs-sdk/src/core/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,10 @@ export const isUserRecurringApproval = (approval: iUserIncomingApproval<bigint>,
}

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;

Expand Down