diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/auctions.ts b/packages/bitbadgesjs-sdk/src/cli/commands/auctions.ts index 6ed98820a1..bc2ab27704 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/auctions.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/auctions.ts @@ -16,6 +16,7 @@ import { } from '../utils/indexer-options.js'; import { requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { resolveAmount } from '../utils/amount.js'; import { doesCollectionFollowAuctionProtocol, @@ -25,34 +26,12 @@ import { buildAuctionBidApproval, buildAcceptAuctionBidMsg } from '../../core/auctions.js'; -import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; -import { BigIntify } from '../../common/string-numbers.js'; import { UintRangeArray } from '../../core/uintRanges.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - const raw = res?.collection ?? res; - if (!raw) return raw; - try { return new BitBadgesCollection(raw).convert(BigIntify); } catch { return raw; } + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validateAuctionCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Auction (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validateAuctionCollection, 'Auction'); } export const auctionsCommand = new Command('auctions').description( diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/bounties.ts b/packages/bitbadgesjs-sdk/src/cli/commands/bounties.ts index d813d19d25..90b90c0cc2 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/bounties.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/bounties.ts @@ -30,6 +30,7 @@ import { } from '../utils/indexer-options.js'; import { requireBb1Address, requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { doesCollectionFollowBountyProtocol, validateBountyCollection, @@ -43,29 +44,11 @@ import { } from '../../core/bounties.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - return res?.collection ?? res; + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validateBountyCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Bounty (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validateBountyCollection, 'Bounty'); } function resolveStatus(collection: any, expirationTime: bigint): BountyStatus { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts index 839b5bbe51..6533d2bf73 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts @@ -745,7 +745,7 @@ sharedOpts( .requiredOption('--pay-amount ', 'Amount you send (display units)') .requiredOption('--receive-denom ', 'What you receive. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') .requiredOption('--receive-amount ', 'Amount you receive (display units)') - .option('--expiration ', 'How long intent stays open (default 30d, matches `bb intents create`)', '30d') + .option('--expiration ', 'Intent expiry: ms-since-epoch (1748140800000) or duration (30d, 24h, monthly). Default 30d, matches `bb intents create`.', '30d') ).action(async (opts) => { const { buildIntent } = await import('../../core/builders/intent.js'); if (opts.json) { emit(buildIntent(readJsonInput(opts.json)), opts); return; } @@ -771,7 +771,7 @@ sharedOpts( .requiredOption('--price ', 'Asking price (display units)') .requiredOption('--denom ', 'Price coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') .option('--max-sales ', 'Maximum number of sales', '1') - .option('--expiration ', 'Listing duration', '30d') + .option('--expiration ', 'Listing expiry: ms-since-epoch (1748140800000) or duration (30d, 24h, monthly). Default 30d.', '30d') ).action(async (opts) => { const { buildListing } = await import('../../core/builders/listing.js'); if (opts.json) { emit(buildListing(readJsonInput(opts.json)), opts); return; } @@ -809,7 +809,7 @@ sharedOpts( .requiredOption('--amount ', 'Number of tokens to sell') .requiredOption('--price ', 'Total payment amount (display units)') .requiredOption('--denom ', 'Payment coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') - .option('--expiration ', 'How long intent stays open', '7d') + .option('--expiration ', 'Intent expiry: ms-since-epoch (1748140800000) or duration (24h, 7d, monthly). Default 24h, matches `bb prediction-markets buy/sell`.', '24h') ).action(async (opts) => { const { buildPmSellIntent } = await import('../../core/builders/pm-sell-intent.js'); if (opts.json) { emit(buildPmSellIntent(readJsonInput(opts.json)), opts); return; } @@ -828,7 +828,7 @@ sharedOpts( .requiredOption('--amount ', 'Number of tokens to buy') .requiredOption('--price ', 'Total payment amount (display units)') .requiredOption('--denom ', 'Payment coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') - .option('--expiration ', 'How long intent stays open', '7d') + .option('--expiration ', 'Intent expiry: ms-since-epoch (1748140800000) or duration (24h, 7d, monthly). Default 24h, matches `bb prediction-markets buy/sell`.', '24h') ).action(async (opts) => { const { buildPmBuyIntent } = await import('../../core/builders/pm-buy-intent.js'); if (opts.json) { emit(buildPmBuyIntent(readJsonInput(opts.json)), opts); return; } diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/credit-tokens.ts b/packages/bitbadgesjs-sdk/src/cli/commands/credit-tokens.ts index 766a43680b..7629051c48 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/credit-tokens.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/credit-tokens.ts @@ -23,6 +23,7 @@ import { import { requireBb1AddressStrict } from '../utils/address.js'; import { resolveNetwork } from '../utils/io.js'; import { addDeployOptions, runEmitOrDeploy, type DeployOpts } from '../utils/deploy-options.js'; +import { normalizeCollection } from '../utils/collection-options.js'; import { doesCollectionFollowCreditTokenProtocol, extractCreditTokenTiers, @@ -31,8 +32,7 @@ import { } from '../../core/credit-tokens.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - return res?.collection ?? res; + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts index 036c129ca6..8f2c3bb32d 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts @@ -12,6 +12,7 @@ import { Command } from 'commander'; import { apiRequest, resolveApiKey, resolveBaseUrl } from '../utils/api-client.js'; import { requireBb1Address, requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { addUnifiedNetworkOptions } from '../utils/network-options.js'; import { resolveAmount } from '../utils/amount.js'; import { emit, emitError } from '../utils/envelope.js'; @@ -46,30 +47,10 @@ async function callApi(method: 'GET' | 'POST', path: string, opts: NetworkFlags, return apiRequest({ method, path, body, apiKey, baseUrl }); } async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - const raw = res?.collection ?? res; - if (!raw) return raw; - try { return new BitBadgesCollection(raw).convert(BigIntify); } catch { return raw; } + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validateCrowdfundCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Crowdfund (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validateCrowdfundCollection, 'Crowdfund'); } async function readRaised(collection: any, opts: NetworkFlags, details: any): Promise { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/pay-requests.ts b/packages/bitbadgesjs-sdk/src/cli/commands/pay-requests.ts index c5d1632faa..b6606963b6 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/pay-requests.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/pay-requests.ts @@ -30,6 +30,7 @@ import { } from '../utils/indexer-options.js'; import { requireBb1Address, requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { doesCollectionFollowPaymentRequestProtocol, validatePaymentRequestCollection, @@ -41,9 +42,7 @@ import { } from '../../core/payment-requests.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - // Indexer returns `{collection: {...}}` for the single-collection GET. - return res?.collection ?? res; + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } /** @@ -52,24 +51,7 @@ async function fetchCollection(collectionId: string, opts: NetworkFlags): Promis * same gate as the frontend view's short-circuit. */ function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validatePaymentRequestCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid PaymentRequest (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validatePaymentRequestCollection, 'PaymentRequest'); } function resolveStatus(collection: any, expirationTime: bigint): PaymentRequestStatus { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/prediction-markets.ts b/packages/bitbadgesjs-sdk/src/cli/commands/prediction-markets.ts index ed55d4585f..bccc127430 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/prediction-markets.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/prediction-markets.ts @@ -26,6 +26,7 @@ import { import { requireBb1AddressStrict } from '../utils/address.js'; import { bbError, BBErrorCode } from '../utils/envelope.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { resolveAmount } from '../utils/amount.js'; import { addExpiryOption, resolveExpiry } from '../utils/expiry-options.js'; import { @@ -40,28 +41,10 @@ import { } from '../../core/prediction-markets.js'; import { UintRangeArray } from '../../core/uintRanges.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - return res?.collection ?? res; + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validatePredictionMarketCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Prediction Market (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validatePredictionMarketCollection, 'Prediction Market'); } /** Resolve settlement approval ids by inspecting collection approvals. */ diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/preview.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/preview.spec.ts new file mode 100644 index 0000000000..3ced85abd4 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/preview.spec.ts @@ -0,0 +1,36 @@ +/** + * preview.ts coverage (ticket 0430) — previously zero specs anywhere. + * ensureTxWrapper normalization + command surface (flag/arg drift). + * The indexer upload + URL assembly are network paths (integration + * territory; no runCli in unit specs, per project convention). + */ + +import { previewCommand, ensureTxWrapper } from './preview.js'; + +describe('preview ensureTxWrapper', () => { + it('returns a {messages:[...]} body unchanged', () => { + const tx = { messages: [{ typeUrl: '/x.Msg', value: { a: 1 } }] }; + expect(ensureTxWrapper(tx)).toBe(tx); + }); + it('wraps a bare {typeUrl,value} Msg into a single-message body', () => { + const msg = { typeUrl: '/x.Msg', value: { a: 1 } }; + expect(ensureTxWrapper(msg)).toEqual({ messages: [msg] }); + }); + it('passes non-object / non-Msg input through (→ invalid_shape downstream)', () => { + expect(ensureTxWrapper(undefined)).toBeUndefined(); + const weird = { not: 'a tx' }; + expect(ensureTxWrapper(weird)).toBe(weird); + }); +}); + +describe('previewCommand shape', () => { + it('takes a single argument', () => { + expect(previewCommand.name()).toBe('preview'); + expect((previewCommand as any)._args.map((a: any) => a.name())).toEqual(['input']); + }); + it('exposes --frontend-url with the bitbadges.io default', () => { + const opt = (previewCommand as any).options.find((o: any) => o.long === '--frontend-url'); + expect(opt).toBeDefined(); + expect(opt.defaultValue).toBe('https://bitbadges.io'); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/preview.ts b/packages/bitbadgesjs-sdk/src/cli/commands/preview.ts index 2171a4fa1d..8eec37b8ac 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/preview.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/preview.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { addNetworkOptions } from '../utils/io.js'; import { addOutputOptions, emit, emitError, commentary } from '../utils/envelope.js'; -function ensureTxWrapper(input: any): any { +export function ensureTxWrapper(input: any): any { if (!input || typeof input !== 'object') return input; if (Array.isArray(input.messages)) return input; if (typeof input.typeUrl === 'string' && input.value) return { messages: [input] }; diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/products.ts b/packages/bitbadgesjs-sdk/src/cli/commands/products.ts index 65d8c18517..27ad6d34a1 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/products.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/products.ts @@ -15,39 +15,18 @@ import { } from '../utils/indexer-options.js'; import { requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { doesCollectionFollowProductCatalogProtocol, validateProductCatalogCollection, extractAllProducts, buildPurchaseProductMsg } from '../../core/products.js'; -import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; -import { BigIntify } from '../../common/string-numbers.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - const raw = res?.collection ?? res; - if (!raw) return raw; - try { return new BitBadgesCollection(raw).convert(BigIntify); } catch { return raw; } + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validateProductCatalogCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Product catalog (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validateProductCatalogCollection, 'Product catalog'); } export const productsCommand = new Command('products').description( diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/simulate.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/simulate.spec.ts new file mode 100644 index 0000000000..b4652c55f0 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/simulate.spec.ts @@ -0,0 +1,44 @@ +/** + * simulate.ts coverage (ticket 0430) — previously zero specs anywhere. + * ensureTxWrapper is the load-bearing input-normalization helper; the + * command surface guards flag drift. Network paths are integration + * territory (no runCli in unit specs, per project convention). + */ + +import { simulateCommand, ensureTxWrapper } from './simulate.js'; + +describe('simulate ensureTxWrapper', () => { + it('returns a {messages:[...]} body unchanged', () => { + const tx = { messages: [{ typeUrl: '/x.Msg', value: { a: 1 } }], memo: 'm' }; + expect(ensureTxWrapper(tx)).toBe(tx); + }); + it('wraps a bare {typeUrl,value} Msg into a single-message body', () => { + const msg = { typeUrl: '/x.Msg', value: { a: 1 } }; + expect(ensureTxWrapper(msg)).toEqual({ messages: [msg] }); + }); + it('passes a non-object through untouched', () => { + expect(ensureTxWrapper(null)).toBeNull(); + expect(ensureTxWrapper('-')).toBe('-'); + expect(ensureTxWrapper(42 as any)).toBe(42); + }); + it('passes an object that is neither messages-shaped nor a Msg through untouched (→ shape error downstream)', () => { + const weird = { foo: 'bar' }; + expect(ensureTxWrapper(weird)).toBe(weird); + // typeUrl present but no value → NOT a valid Msg, passthrough + const noValue = { typeUrl: '/x.Msg' }; + expect(ensureTxWrapper(noValue)).toBe(noValue); + }); +}); + +describe('simulateCommand shape', () => { + it('takes a single argument', () => { + expect(simulateCommand.name()).toBe('simulate'); + expect((simulateCommand as any)._args.map((a: any) => a.name())).toEqual(['input']); + }); + it('exposes the documented flags', () => { + const flags = (simulateCommand as any).options.map((o: any) => o.long); + for (const f of ['--creator', '--events', '--condensed', '--output-file']) { + expect(flags).toContain(f); + } + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/simulate.ts b/packages/bitbadgesjs-sdk/src/cli/commands/simulate.ts index 2626a02baa..f0cf2c64d8 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/simulate.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/simulate.ts @@ -7,7 +7,7 @@ import { addNetworkOptions } from '../utils/io.js'; * Msg (wrapped into a single-message tx body), or anything else (passed * through untouched). */ -function ensureTxWrapper(input: any): any { +export function ensureTxWrapper(input: any): any { if (!input || typeof input !== 'object') return input; if (Array.isArray(input.messages)) return input; if (typeof input.typeUrl === 'string' && input.value) return { messages: [input] }; diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/smart-tokens.ts b/packages/bitbadgesjs-sdk/src/cli/commands/smart-tokens.ts index bf2612ce22..3c3b69d7b8 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/smart-tokens.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/smart-tokens.ts @@ -28,6 +28,7 @@ import { } from '../utils/indexer-options.js'; import { requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; import { doesCollectionFollowSmartTokenProtocol, validateSmartTokenCollection, @@ -36,34 +37,12 @@ import { buildSmartTokenWithdrawMsg } from '../../core/smart-tokens.js'; import { resolveCoin, toBaseUnits } from '../../core/builders/shared.js'; -import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; -import { BigIntify } from '../../common/string-numbers.js'; async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - const raw = res?.collection ?? res; - if (!raw) return raw; - try { return new BitBadgesCollection(raw).convert(BigIntify); } catch { return raw; } + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } function validateOrExit(collection: any, ctx: string): void { - if (!collection) { - process.stderr.write(`Error: collection not found while running ${ctx}.\n`); - process.exit(2); - } - const result = validateSmartTokenCollection(collection); - if (!result.valid) { - process.stderr.write(`Error: collection is not a valid Smart Token (failed in ${ctx}):\n`); - for (const e of result.errors) process.stderr.write(` - ${e}\n`); - if (result.warnings.length > 0) { - process.stderr.write('Warnings:\n'); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } - process.exit(2); - } - if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { - process.stderr.write(`Warnings for ${ctx}:\n`); - for (const w of result.warnings) process.stderr.write(` - ${w}\n`); - } + validateCollectionOrExit(collection, ctx, validateSmartTokenCollection, 'Smart Token'); } /** diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.ts b/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.ts index e60362aa14..14a72afffe 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.ts @@ -31,6 +31,7 @@ import { } from '../utils/indexer-options.js'; import { requireBb1Address, requireBb1AddressStrict } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection } from '../utils/collection-options.js'; import { doesCollectionFollowSubscriptionProtocol, isSubscriptionFaucetApproval, @@ -40,7 +41,6 @@ import { } from '../../core/subscriptions.js'; import { getBalanceForIdAndTime } from '../../core/balances.js'; import { UintRangeArray } from '../../core/uintRanges.js'; -import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; import { BalanceDoc } from '../../api-indexer/docs-types/docs.js'; import { BigIntify } from '../../common/string-numbers.js'; @@ -59,16 +59,7 @@ function parseNonNegativeIntFlag(value: string | undefined, flagName: string): b } async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { - const res = await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts); - const raw = res?.collection ?? res; - if (!raw) return raw; - // Indexer ships uint64s as strings; validators below expect bigints. - // Convert at the boundary so downstream protocol checks compare like-for-like. - try { - return new BitBadgesCollection(raw).convert(BigIntify); - } catch { - return raw; - } + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); } async function fetchUserBalances(collectionId: string, address: string, opts: NetworkFlags): Promise { diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.ts b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.ts new file mode 100644 index 0000000000..cc549c7ee8 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.ts @@ -0,0 +1,65 @@ +/** + * Shared single-collection fetch normalization + validate-or-exit + * scaffolding for the standard command files (ticket 0429). + * + * - `normalizeCollection` — unwrap an indexer `/collection` response + * and convert uint64 strings → bigint via the SDK class. This was + * copy-pasted in 5 command files and **silently skipped in 4** + * (credit-tokens, pay-requests, bounties, prediction-markets), which + * handed string-typed amounts to bigint-comparing validators — a + * latent boundary bug. One implementation; always normalize. + * - `validateCollectionOrExit` — the byte-identical "not found → exit + * 2 / collect validator errors+warnings → print → exit / echo + * warnings unless BB_QUIET" block reimplemented in 7 files, + * parameterized only by the per-standard validator + label. + * + * The boolean-protocol `validateOrExit` variants (subscriptions, + * credit-tokens) carry bespoke hint text and are only 2 sites — left + * as-is per the no-churn philosophy. + */ +import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; +import { BigIntify } from '../../common/string-numbers.js'; + +/** Unwrap `{collection}|raw` and normalize numeric strings → bigint. */ +export function normalizeCollection(res: any): any { + const raw = res?.collection ?? res; + if (!raw) return raw; + try { + return new BitBadgesCollection(raw).convert(BigIntify); + } catch { + return raw; + } +} + +export interface CollectionValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** Shared not-found + validator-result print/exit gate (exit code 2). */ +export function validateCollectionOrExit( + collection: any, + ctx: string, + validate: (c: any) => CollectionValidationResult, + label: string +): void { + if (!collection) { + process.stderr.write(`Error: collection not found while running ${ctx}.\n`); + process.exit(2); + } + const result = validate(collection); + if (!result.valid) { + process.stderr.write(`Error: collection is not a valid ${label} (failed in ${ctx}):\n`); + for (const e of result.errors) process.stderr.write(` - ${e}\n`); + if (result.warnings.length > 0) { + process.stderr.write('Warnings:\n'); + for (const w of result.warnings) process.stderr.write(` - ${w}\n`); + } + process.exit(2); + } + if (result.warnings.length > 0 && process.env.BB_QUIET !== '1') { + process.stderr.write(`Warnings for ${ctx}:\n`); + for (const w of result.warnings) process.stderr.write(` - ${w}\n`); + } +} diff --git a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts index 842064632f..354b081339 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts @@ -759,6 +759,10 @@ describe('intent builder (delegates to canonical)', () => { expect(() => buildIntent({ address: 'bb1a', collectionId: '1', payDenom: 'USDC', payAmount: 10, receiveDenom: 'usdc', receiveAmount: 5 })) .toThrow(/denoms must differ/); }); + test('accepts ms-since-epoch expiration (parity with bb intents create; durationToTimestamp rejected it)', () => { + const a = buildIntent({ address: 'bb1c', collectionId: '5', payDenom: 'USDC', payAmount: 7, receiveDenom: 'BADGE', receiveAmount: 3, expiration: '1798765432000' }).value.approval; + expect(a.transferTimes).toEqual([{ start: 1n, end: 1798765432000n }]); + }); }); // `recurring-payment builder` block removed — buildRecurringPayment was @@ -803,6 +807,10 @@ describe('listing builder (delegates to canonical)', () => { expect(buildListing(p).value.approval.approvalId).toBe(buildListing(p).value.approval.approvalId); expect((msg as any)._meta).toBeUndefined(); }); + test('accepts ms-since-epoch expiration (parity with bb nfts list; durationToTimestamp rejected it)', () => { + const a = buildListing({ address: 'bb1seller', collectionId: '1', tokenIds: '4', price: 50, denom: 'USDC', expiration: '1798765432000' }).value.approval; + expect(a.transferTimes).toEqual([{ start: 1n, end: 1798765432000n }]); + }); }); describe('bid builder (delegates to canonical)', () => { @@ -914,6 +922,14 @@ describe('pm-sell-intent builder (delegates to canonical)', () => { const b = buildPmSellIntent({ address: 'bb1s', collectionId: '7', token: 'yes', amount: 3, price: 9, denom: 'USDC' }); expect(a.value.approval.approvalId).toBe(b.value.approval.approvalId); }); + test('accepts ms-since-epoch expiration; defaults to a 24h window (parity with bb prediction-markets sell)', () => { + const fixed = buildPmSellIntent({ address: 'bb1s', collectionId: '7', token: 'yes', amount: 3, price: 9, denom: 'USDC', expiration: '1798765432000' }).value.approval; + expect(fixed.transferTimes).toEqual([{ start: 1n, end: 1798765432000n }]); + const def = buildPmSellIntent({ address: 'bb1s', collectionId: '7', token: 'yes', amount: 3, price: 9, denom: 'USDC' }).value.approval; + const span = Number(def.transferTimes[0].end) - Date.now(); + expect(span).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(span).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000); + }); }); describe('pm-buy-intent builder (delegates to canonical)', () => { @@ -950,6 +966,14 @@ describe('pm-buy-intent builder (delegates to canonical)', () => { expect(a.value.approval.approvalId).toBe(b.value.approval.approvalId); expect(buildPmBuyIntent({ ...p, price: 31 }).value.approval.approvalId).not.toBe(a.value.approval.approvalId); }); + test('accepts ms-since-epoch expiration; defaults to a 24h window (parity with bb prediction-markets buy)', () => { + const fixed = buildPmBuyIntent({ address: 'bb1buyer', collectionId: '42', token: 'no', amount: 200, price: 30, denom: 'USDC', expiration: '1798765432000' }).value.approval; + expect(fixed.transferTimes).toEqual([{ start: 1n, end: 1798765432000n }]); + const def = buildPmBuyIntent({ address: 'bb1buyer', collectionId: '42', token: 'no', amount: 200, price: 30, denom: 'USDC' }).value.approval; + const span = Number(def.transferTimes[0].end) - Date.now(); + expect(span).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(span).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000); + }); }); // ── Zero-violations suite: every builder must pass ALL standard checks ─────── diff --git a/packages/bitbadgesjs-sdk/src/core/builders/intent.ts b/packages/bitbadgesjs-sdk/src/core/builders/intent.ts index ab1fb6ce90..da58dec6e4 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/intent.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/intent.ts @@ -8,10 +8,12 @@ * * @module core/builders/intent */ -import { resolveCoin, toBaseUnits, durationToTimestamp, stableHashId } from './shared.js'; +import { resolveCoin, toBaseUnits, resolveExpiration, stableHashId } from './shared.js'; import { buildIntentApproval, type IntentApprovalArgs } from '../intents.js'; import { UintRangeArray } from '../uintRanges.js'; +const INTENT_DEFAULT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; + export interface IntentParams { address: string; // creator bb1... address collectionId: string; // Intent Exchange collection ID @@ -19,7 +21,7 @@ export interface IntentParams { payAmount: number; // display units receiveDenom: string; // what creator receives receiveAmount: number; // display units - expiration?: string; // duration shorthand, default "30d" + expiration?: string; // ms-since-epoch or duration shorthand, default "30d" } export function buildIntent(params: IntentParams): { typeUrl: string; value: any } { @@ -30,7 +32,7 @@ export function buildIntent(params: IntentParams): { typeUrl: string; value: any if (payCoin.denom === receiveCoin.denom) { throw new Error('Intent pay and receive denoms must differ.'); } - const end = BigInt(durationToTimestamp(params.expiration || '30d')); + const end = resolveExpiration(params.expiration, INTENT_DEFAULT_EXPIRY_MS); const approvalId = stableHashId('intent', { address: params.address, collectionId: params.collectionId, diff --git a/packages/bitbadgesjs-sdk/src/core/builders/listing.ts b/packages/bitbadgesjs-sdk/src/core/builders/listing.ts index d9902ec071..1db563deee 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/listing.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/listing.ts @@ -7,10 +7,12 @@ * * @module core/builders/listing */ -import { resolveCoin, toBaseUnits, durationToTimestamp, stableHashId } from './shared.js'; +import { resolveCoin, toBaseUnits, resolveExpiration, stableHashId } from './shared.js'; import { buildOrderbookListingApproval, type OrderbookOrderArgs } from '../bids.js'; import { UintRangeArray } from '../uintRanges.js'; +const LISTING_DEFAULT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; + export interface ListingParams { address: string; // seller bb1... address collectionId: string; // collection to list from @@ -18,7 +20,7 @@ export interface ListingParams { price: number; // asking price (display units) denom: string; // price coin (USDC, BADGE) maxSales?: number; // max number of sales, default 1 - expiration?: string; // listing duration, default "30d" + expiration?: string; // ms-since-epoch or duration shorthand, default "30d" } /** Orderbook listings are single-token. Accept "5" or "5-5"; reject a true range. */ @@ -35,7 +37,7 @@ function singleTokenId(input: string, ctx: string): bigint { export function buildListing(params: ListingParams): { typeUrl: string; value: any } { const coin = resolveCoin(params.denom); const tokenId = singleTokenId(params.tokenIds, 'listing'); - const end = BigInt(durationToTimestamp(params.expiration || '30d')); + const end = resolveExpiration(params.expiration, LISTING_DEFAULT_EXPIRY_MS); const maxNumTransfers = BigInt(params.maxSales || 1); const approvalId = stableHashId('listing', { address: params.address, diff --git a/packages/bitbadgesjs-sdk/src/core/builders/pm-buy-intent.ts b/packages/bitbadgesjs-sdk/src/core/builders/pm-buy-intent.ts index 61830f5299..5dd30e2d5e 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/pm-buy-intent.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/pm-buy-intent.ts @@ -10,10 +10,13 @@ * * @module core/builders/pm-buy-intent */ -import { resolveCoin, toBaseUnits, durationToTimestamp, stableHashId } from './shared.js'; +import { resolveCoin, toBaseUnits, resolveExpiration, stableHashId } from './shared.js'; import { buildPredictionMarketBuyIntent, type PredictionMarketSideArgs } from '../prediction-markets.js'; import { UintRangeArray } from '../uintRanges.js'; +// Matches the end-user `bb prediction-markets buy-*` default (24h). +const PM_INTENT_DEFAULT_EXPIRY_MS = 24 * 60 * 60 * 1000; + export interface PmBuyIntentParams { address: string; // buyer bb1... address collectionId: string; // prediction market collection ID @@ -21,7 +24,7 @@ export interface PmBuyIntentParams { amount: number; // number of tokens to buy (unitless count) price: number; // total payment amount (display units) denom: string; // payment coin (USDC, BADGE) - expiration?: string; // duration shorthand, default "7d" + expiration?: string; // ms-since-epoch or duration shorthand, default "24h" } export function buildPmBuyIntent(params: PmBuyIntentParams): { typeUrl: string; value: any } { @@ -32,7 +35,7 @@ export function buildPmBuyIntent(params: PmBuyIntentParams): { typeUrl: string; } const coin = resolveCoin(params.denom); const tokenId = params.token === 'yes' ? 1n : 2n; - const end = BigInt(durationToTimestamp(params.expiration || '7d')); + const end = resolveExpiration(params.expiration, PM_INTENT_DEFAULT_EXPIRY_MS); const approvalId = stableHashId('pm-buy', { address: params.address, collectionId: params.collectionId, diff --git a/packages/bitbadgesjs-sdk/src/core/builders/pm-sell-intent.ts b/packages/bitbadgesjs-sdk/src/core/builders/pm-sell-intent.ts index e580736480..9059855ada 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/pm-sell-intent.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/pm-sell-intent.ts @@ -10,10 +10,13 @@ * * @module core/builders/pm-sell-intent */ -import { resolveCoin, toBaseUnits, durationToTimestamp, stableHashId } from './shared.js'; +import { resolveCoin, toBaseUnits, resolveExpiration, stableHashId } from './shared.js'; import { buildPredictionMarketSellIntent, type PredictionMarketSideArgs } from '../prediction-markets.js'; import { UintRangeArray } from '../uintRanges.js'; +// Matches the end-user `bb prediction-markets sell-*` default (24h). +const PM_INTENT_DEFAULT_EXPIRY_MS = 24 * 60 * 60 * 1000; + export interface PmSellIntentParams { address: string; // seller bb1... address collectionId: string; // prediction market collection ID @@ -21,7 +24,7 @@ export interface PmSellIntentParams { amount: number; // number of tokens to sell (unitless count) price: number; // total payment amount (display units) denom: string; // payment coin (USDC, BADGE) - expiration?: string; // duration shorthand, default "7d" + expiration?: string; // ms-since-epoch or duration shorthand, default "24h" } export function buildPmSellIntent(params: PmSellIntentParams): { typeUrl: string; value: any } { @@ -32,7 +35,7 @@ export function buildPmSellIntent(params: PmSellIntentParams): { typeUrl: string } const coin = resolveCoin(params.denom); const tokenId = params.token === 'yes' ? 1n : 2n; - const end = BigInt(durationToTimestamp(params.expiration || '7d')); + const end = resolveExpiration(params.expiration, PM_INTENT_DEFAULT_EXPIRY_MS); const approvalId = stableHashId('pm-sell', { address: params.address, collectionId: params.collectionId,