diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/assets.ts b/packages/bitbadgesjs-sdk/src/cli/commands/assets.ts index 45f177fb81..b1092d8bab 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/assets.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/assets.ts @@ -28,6 +28,7 @@ import { type IndexerNetworkFlags as NetworkFlags, type IndexerOutputFlags as OutputFlags, } from '../utils/indexer-options.js'; +import { splitCsv } from '../utils/csv-options.js'; function fail(code: number, msg: string): never { process.stderr.write(`Error: ${msg}\n`); @@ -144,7 +145,7 @@ addOutputFlags( ) ).action(async (rawInputs: string[], opts: NetworkFlags & OutputFlags) => { try { - const inputs = rawInputs.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); + const inputs = splitCsv(rawInputs); if (inputs.length === 0) fail(2, 'at least one denom or symbol required'); const denoms: { input: string; denom?: string }[] = []; diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts index 64c9fda3f6..839b5bbe51 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts @@ -786,16 +786,17 @@ sharedOpts( .description('Create a marketplace bid (user incoming approval)') .requiredOption('--address
', 'Bidder address (bb1...)') .requiredOption('--collection-id ', 'Collection ID to bid on') - .requiredOption('--token-ids ', 'Token ID range (e.g. "1-5" or "1")') + .option('--token-ids ', 'Single token ID to bid on (e.g. "1" or "1-1"). Omit for a collection-wide bid (parity with `bb nfts bid`).') + .option('--token-amount ', 'Number of tokens (default 1)', '1') .requiredOption('--price ', 'Bid price (display units)') .requiredOption('--denom ', 'Price coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') - .option('--expiration ', 'Bid duration', '7d') + .option('--expiration ', 'Bid expiry: ms-since-epoch (1748140800000) or duration (7d, 24h, monthly). Default 7d.', '7d') ).action(async (opts) => { const { buildBid } = await import('../../core/builders/bid.js'); if (opts.json) { emit(buildBid(readJsonInput(opts.json)), opts); return; } const denom = requireBbDenom(opts.denom, '--denom'); const address = requireBb1AddressStrict(opts.address, '--address'); - emit(buildBid({ address, collectionId: opts.collectionId, tokenIds: opts.tokenIds, price: Number(opts.price), denom, expiration: opts.expiration }), opts); + emit(buildBid({ address, collectionId: opts.collectionId, tokenIds: opts.tokenIds, tokenAmount: Number(opts.tokenAmount), price: Number(opts.price), denom, expiration: opts.expiration }), opts); }); sharedOpts( diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/custom-2fa.ts b/packages/bitbadgesjs-sdk/src/cli/commands/custom-2fa.ts index ae68552e13..730f3eff5e 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/custom-2fa.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/custom-2fa.ts @@ -18,7 +18,7 @@ import { type IndexerNetworkFlags as NetworkFlags, type IndexerOutputFlags as OutputFlags, } from '../utils/indexer-options.js'; -import { requireBb1AddressStrict } from '../utils/address.js'; +import { requireBb1AddressStrict, resolveRecipientList } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; import { addExpiryOption, resolveExpiry } from '../utils/expiry-options.js'; import { mintCustom2FA, CUSTOM_2FA_TOKEN_EXPIRATION_MS } from '../../core/builders/custom-2fa.js'; @@ -50,11 +50,7 @@ addDeployOptions( ) => { try { const creator = requireBb1AddressStrict(opts.creator, '--creator'); - const recipients = String(opts.to) - .split(',') - .map((a) => a.trim()) - .filter(Boolean) - .map((a) => requireBb1AddressStrict(a, '--to')); + const recipients = resolveRecipientList(opts.to, '--to'); const endMs = resolveExpiry(opts, CUSTOM_2FA_TOKEN_EXPIRATION_MS); const expirationMs = Number(endMs - BigInt(Date.now())); if (expirationMs <= 0) { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/dynamic-stores.ts b/packages/bitbadgesjs-sdk/src/cli/commands/dynamic-stores.ts index 34476dde1b..b7763bf852 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/dynamic-stores.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/dynamic-stores.ts @@ -37,18 +37,15 @@ import { type IndexerNetworkFlags as NetworkFlags, type IndexerOutputFlags as OutputFlags, } from '../utils/indexer-options.js'; -import { requireBb1Address, requireBb1AddressStrict } from '../utils/address.js'; +import { requireBb1Address, requireBb1AddressStrict, resolveRecipientList } from '../utils/address.js'; import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { splitCsv } from '../utils/csv-options.js'; function fail(code: number, msg: string): never { process.stderr.write(`Error: ${msg}\n`); process.exit(code); } -function splitCsv(values: string[]): string[] { - return values.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); -} - function parseBool(v: string, flagName: string): boolean { const lower = v.toLowerCase(); if (['true', 't', '1', 'yes', 'y'].includes(lower)) return true; @@ -190,7 +187,7 @@ Examples: function bulkSetValue(value: boolean) { return (storeId: string, rawAddresses: string[], opts: OutputFlags & { creator: string }) => { const creator = requireBb1AddressStrict(opts.creator, '--creator'); - const addresses = splitCsv(rawAddresses).map((a) => requireBb1AddressStrict(a, ' argument')); + const addresses = resolveRecipientList(rawAddresses, ' argument'); if (addresses.length === 0) fail(2, 'at least one address required'); const messages = addresses.map((address) => ({ typeUrl: '/tokenization.MsgSetDynamicStoreValue', diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/pairs.ts b/packages/bitbadgesjs-sdk/src/cli/commands/pairs.ts index c12bf43247..c43708c231 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/pairs.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/pairs.ts @@ -29,6 +29,7 @@ import { emitIndexerError as emitError, } from '../utils/indexer-options.js'; import { appendQuery } from '../utils/list-options.js'; +import { splitCsv } from '../utils/csv-options.js'; const ANALYTICS_VERBS: ReadonlyArray = [ ['top-gainers', '/assetPairs/topGainers', 'Top-gaining asset pairs in the last 24h.'], @@ -95,7 +96,7 @@ export function registerPairs(parent: Command): void { .argument('', 'Denoms (repeated or comma-separated)') ).action(async (denoms: string[], opts: any) => { try { - const list = denoms.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); + const list = splitCsv(denoms); const res = await callApi('POST', '/assetPairs/byDenoms', opts, { denoms: list }); emit(res, opts); } catch (err) { diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/pools.ts b/packages/bitbadgesjs-sdk/src/cli/commands/pools.ts index bd7e3f0cb9..dc69f2dc7c 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/pools.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/pools.ts @@ -29,6 +29,7 @@ import { } from '../utils/indexer-options.js'; import { requireBbDenom } from '../utils/denom.js'; import { appendQuery } from '../utils/list-options.js'; +import { splitCsv } from '../utils/csv-options.js'; /** * Mount the pool browser subcommand tree under `parent`. Used by both: @@ -108,7 +109,7 @@ export function registerPools(parent: Command): void { .argument('', 'Pool IDs') ).action(async (poolIds: string[], opts: any) => { try { - const ids = poolIds.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); + const ids = splitCsv(poolIds); if (ids.length === 0) { process.stderr.write('Error: at least one pool ID required.\n'); process.exit(2); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/price.ts b/packages/bitbadgesjs-sdk/src/cli/commands/price.ts index a7b9814f70..0b21e44e47 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/price.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/price.ts @@ -19,6 +19,7 @@ import { Command } from 'commander'; import { addOutputOptions, emit, errorEnvelope, writeJsonEnvelope } from '../utils/envelope.js'; import { apiRequest, resolveApiKey, resolveBaseUrl } from '../utils/api-client.js'; import { addUnifiedNetworkOptions } from '../utils/network-options.js'; +import { splitCsv } from '../utils/csv-options.js'; interface PriceFlags { testnet?: boolean; @@ -38,10 +39,6 @@ interface AssetPairRow { lastUpdated?: string; } -function splitCsv(values: string[]): string[] { - return values.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); -} - async function callApi(method: 'GET' | 'POST', path: string, opts: PriceFlags, body?: unknown): Promise { const network = opts.testnet ? 'testnet' : opts.local ? 'local' : 'mainnet'; const apiKey = resolveApiKey(opts.apiKey, network); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.spec.ts index 18e9b966a5..ee068e5604 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/subscriptions.spec.ts @@ -61,6 +61,14 @@ describe('subscriptionsCommand shape', () => { } }); + it('enable-renewal + subscribe expose --approval-id (#0418 pinnable recurring id)', () => { + for (const verb of ['enable-renewal', 'subscribe']) { + const c = subscriptionsCommand.commands.find((cmd) => cmd.name() === verb); + const flagNames = (c! as any).options.map((o: any) => o.long); + expect(flagNames).toContain('--approval-id'); + } + }); + it('no longer registers a `build` subcommand — use `bb build subscription` instead', () => { const build = subscriptionsCommand.commands.find((c) => c.name() === 'build'); expect(build).toBeUndefined(); diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/cli-build-pipeline.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/cli-build-pipeline.spec.ts index 2fcf446dfd..c7c1821a0a 100644 --- a/packages/bitbadgesjs-sdk/src/cli/integration/cli-build-pipeline.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/integration/cli-build-pipeline.spec.ts @@ -187,6 +187,17 @@ describe('cli build pipeline integration', () => { expect(out.json.value.creator).toBe(CREATOR); expect(out.json.value.approval.fromListId).toBe('All'); }); + it('omitting --token-ids yields a collection-wide bid (parity with bb nfts bid)', () => { + const out = runCli([ + 'build', 'bid', + '--address', CREATOR, '--collection-id', '1', + '--price', '5', '--denom', 'USDC' + ]); + expect(out.json.typeUrl).toBe('/tokenization.MsgSetIncomingApproval'); + expect( + out.json.value.approval.approvalCriteria.predeterminedBalances.incrementedBalances.allowOverrideWithAnyValidToken + ).toBe(true); + }); }); describe('bb build intent', () => { diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/subscriptions.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/subscriptions.spec.ts index 5eb82177fb..305a549474 100644 --- a/packages/bitbadgesjs-sdk/src/cli/integration/subscriptions.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/integration/subscriptions.spec.ts @@ -155,16 +155,24 @@ describe('subscriptions integration', () => { if (!ready || !collectionId || !approvalId) return; const subscriber = charlie(); + // #0427: pin the recurring-approval id via --approval-id and prove it + // flows through to the emitted MsgUpdateUserApprovals (the point of + // #0418 — without a pin the id is random and not replayable). + const PINNED_RENEWAL_ID = 'pinned-renewal-0427'; const msg = runCli([ 'subscriptions', 'enable-renewal', collectionId, '--creator', subscriber.address, '--tier', approvalId, + '--approval-id', PINNED_RENEWAL_ID, '--local' ]); expect(msg.json.typeUrl).toBe('/tokenization.MsgUpdateUserApprovals'); expect(msg.json.value.updateIncomingApprovals).toBe(true); expect(Array.isArray(msg.json.value.incomingApprovals)).toBe(true); expect(msg.json.value.incomingApprovals.length).toBeGreaterThan(0); + expect( + msg.json.value.incomingApprovals.some((a: any) => a.approvalId === PINNED_RENEWAL_ID) + ).toBe(true); const tmp = writeMsgToTmp(msg.json, 'sub-enable'); const tx = await deployMsgViaKeyring(tmp, subscriber.name); diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/address.spec.ts b/packages/bitbadgesjs-sdk/src/cli/utils/address.spec.ts index 4ff32b6d1a..891632d526 100644 --- a/packages/bitbadgesjs-sdk/src/cli/utils/address.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/utils/address.spec.ts @@ -5,7 +5,7 @@ * malformed input, stderr normalization notice gating). */ -import { tryBb1Address, requireBb1Address, requireBb1AddressStrict } from './address.js'; +import { tryBb1Address, requireBb1Address, requireBb1AddressStrict, resolveRecipientList } from './address.js'; // Zero-address pair — deterministic and stable across SDK versions. // 0x000...000 ↔ bb1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7gvmv. @@ -137,3 +137,44 @@ describe('requireBb1AddressStrict', () => { expect(() => requireBb1AddressStrict(ZERO_ETH, '--creator')).toThrow('process.exit'); }); }); + +describe('resolveRecipientList', () => { + let exitSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation(((_code?: number) => { + throw new Error('process.exit'); + }) as never); + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('splits a comma-joined string and strictly validates each element', () => { + expect(resolveRecipientList(`${ZERO_BB1}, ${ZERO_BB1}`, '--to')).toEqual([ZERO_BB1, ZERO_BB1]); + }); + + it('accepts the repeatable-argument (string[]) form', () => { + expect(resolveRecipientList([ZERO_BB1, `${ZERO_BB1},${ZERO_BB1}`], '--to')).toEqual([ + ZERO_BB1, ZERO_BB1, ZERO_BB1 + ]); + }); + + it('does not dedupe (downstream builders own that)', () => { + expect(resolveRecipientList(`${ZERO_BB1},${ZERO_BB1}`, '--to')).toHaveLength(2); + }); + + it('rejects a malformed element via requireBb1AddressStrict', () => { + expect(() => resolveRecipientList(`${ZERO_BB1},bb1garbage`, '--to')).toThrow('process.exit'); + const stderrText = stderrSpy.mock.calls.map((c) => c[0]).join(''); + expect(stderrText).toContain('invalid bb1 address'); + }); + + it('rejects a non-bb1 (0x) element', () => { + expect(() => resolveRecipientList(ZERO_ETH, '--to')).toThrow('process.exit'); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/address.ts b/packages/bitbadgesjs-sdk/src/cli/utils/address.ts index 345cf296ae..ac80f68fe1 100644 --- a/packages/bitbadgesjs-sdk/src/cli/utils/address.ts +++ b/packages/bitbadgesjs-sdk/src/cli/utils/address.ts @@ -22,6 +22,7 @@ */ import { convertToBitBadgesAddress } from '../../address-converter/converter.js'; +import { splitCsv } from './csv-options.js'; /** * Convert any supported address form (0x, bb1, bbvaloper) to its canonical @@ -128,3 +129,16 @@ export function requireBb1AddressStrict(address: string, flagName: string): stri } process.exit(1); } + +/** + * Split a comma-joined (and/or repeatable) recipient input into a list + * of canonical bb1 addresses, strictly validating each element via + * {@link requireBb1AddressStrict}. Replaces the + * split→trim→filter→map(requireBb1AddressStrict) sequence reimplemented + * in `custom-2fa` and `dynamic-stores` (ticket 0423). Does not dedupe — + * downstream builders own that where it matters (e.g. custom-2fa mint). + */ +export function resolveRecipientList(raw: string | string[], flagName: string): string[] { + const values = Array.isArray(raw) ? raw : [String(raw ?? '')]; + return splitCsv(values).map((tok) => requireBb1AddressStrict(tok, flagName)); +} diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.spec.ts b/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.spec.ts new file mode 100644 index 0000000000..b7e9bfd0b5 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.spec.ts @@ -0,0 +1,30 @@ +/** + * Tests for the shared CSV / repeatable-flag splitter (ticket 0423). + * Replaces two byte-identical private `splitCsv` clones + three inlined + * sites — this is the single source of truth they now delegate to. + */ + +import { splitCsv } from './csv-options.js'; + +describe('splitCsv', () => { + it('splits a single comma-joined value', () => { + expect(splitCsv(['a,b,c'])).toEqual(['a', 'b', 'c']); + }); + + it('flattens repeatable + comma-joined occurrences', () => { + expect(splitCsv(['a,b', 'c', 'd,e'])).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('trims whitespace and drops empty tokens', () => { + expect(splitCsv([' a , ,b ', '', ' ', ',c,'])).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty array for no usable input', () => { + expect(splitCsv([])).toEqual([]); + expect(splitCsv(['', ' , , '])).toEqual([]); + }); + + it('does not dedupe (callers decide)', () => { + expect(splitCsv(['x,x', 'x'])).toEqual(['x', 'x', 'x']); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.ts b/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.ts new file mode 100644 index 0000000000..8bb55b01aa --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/utils/csv-options.ts @@ -0,0 +1,15 @@ +/** + * Shared CSV / repeatable-flag value splitter (ticket 0423). + * + * Commander gives a repeatable flag/argument as `string[]`; users also + * comma-join values within a single occurrence. The normalization + * "flatten on commas, trim, drop empties" was copy-pasted as two + * byte-identical private `splitCsv` clones (dynamic-stores, price) and + * inlined three more times (assets, pools, pairs). One implementation + * so a future change to the splitting/normalization lands everywhere. + */ + +/** Split repeatable + comma-joined flag values into a clean token list. */ +export function splitCsv(values: string[]): string[] { + return values.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean); +} diff --git a/packages/bitbadgesjs-sdk/src/core/builders/bid.ts b/packages/bitbadgesjs-sdk/src/core/builders/bid.ts index 93b4d65bb4..dd1db8d95a 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/bid.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/bid.ts @@ -6,25 +6,28 @@ * * @module core/builders/bid */ -import { resolveCoin, toBaseUnits, durationToTimestamp, stableHashId } from './shared.js'; +import { resolveCoin, toBaseUnits, resolveExpiration, stableHashId } from './shared.js'; import { buildOrderbookBidApproval, type OrderbookOrderArgs } from '../bids.js'; import { UintRangeArray } from '../uintRanges.js'; +const BID_DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + export interface BidParams { address: string; // bidder bb1... address collectionId: string; // collection to bid on - tokenIds: string; // single token id (orderbook bids are single-token here) + tokenIds?: string; // single token id; omit for a collection-wide bid (parity with `bb nfts bid`) + tokenAmount?: number; // token quantity, default 1 price: number; // bid price (display units) denom: string; // price coin (USDC, BADGE) - expiration?: string; // bid duration, default "7d" + expiration?: string; // ms-since-epoch or duration shorthand, default "7d" } -/** Orderbook bids via the build verb are single-token. Accept "5" or "5-5"; reject a true range. */ +/** A single-token bid accepts "5" or "5-5"; a true range is rejected (omit token-ids for collection-wide). */ function singleTokenId(input: string, ctx: string): bigint { const parts = String(input).split('-').map((s) => s.trim()); if (parts.length === 2 && parts[0] !== parts[1]) { throw new Error( - `${ctx} supports a single token id (got range "${input}"). Bid per token id, or use \`bb nfts bid\` (omit --token-id) for a collection-wide bid.` + `${ctx} supports a single token id (got range "${input}"). Bid per token id, or omit --token-ids for a collection-wide bid.` ); } return BigInt(parts[0]); @@ -32,22 +35,25 @@ function singleTokenId(input: string, ctx: string): bigint { export function buildBid(params: BidParams): { typeUrl: string; value: any } { const coin = resolveCoin(params.denom); - const tokenId = singleTokenId(params.tokenIds, 'bid'); - const end = BigInt(durationToTimestamp(params.expiration || '7d')); + const hasTokenId = typeof params.tokenIds === 'string' && params.tokenIds.trim().length > 0; + const tokenId = hasTokenId ? singleTokenId(params.tokenIds as string, 'bid') : undefined; + const tokenAmount = BigInt(params.tokenAmount ?? 1); + const end = resolveExpiration(params.expiration, BID_DEFAULT_EXPIRY_MS); const approvalId = stableHashId('bid', { address: params.address, collectionId: params.collectionId, - tokenIds: params.tokenIds, + tokenIds: hasTokenId ? (params.tokenIds as string).trim() : 'all', + tokenAmount: String(tokenAmount), price: params.price, denom: coin.denom, - expiration: params.expiration || '7d' + expiration: params.expiration || `${BID_DEFAULT_EXPIRY_MS}ms` }); const args: OrderbookOrderArgs = { address: params.address, tokenId, paymentAmount: BigInt(toBaseUnits(params.price, coin.decimals)), paymentDenom: coin.denom, - tokenAmount: 1n, + tokenAmount, transferTimes: UintRangeArray.From([{ start: 1n, end }]), approvalId, maxNumTransfers: 1n diff --git a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts index 71620f82c0..842064632f 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts @@ -105,6 +105,19 @@ describe('shared utilities', () => { expect(toBaseUnits(0.5, 6)).toBe('500000'); }); + test('toBaseUnits throws on invalid amounts instead of silently coercing', () => { + expect(() => toBaseUnits(-1, 6)).toThrow(/non-negative/i); + expect(() => toBaseUnits(Infinity, 6)).toThrow(/finite/i); + expect(() => toBaseUnits(NaN, 6)).toThrow(/finite/i); + // Numeric-string tolerance (the old implicit-coercion path) is preserved. + expect(toBaseUnits('10' as any, 6)).toBe('10000000'); + // Non-numeric / undefined no longer silently become "NaN". + expect(() => toBaseUnits('abc' as any, 6)).toThrow(/finite/i); + expect(() => toBaseUnits(undefined as any, 6)).toThrow(/finite/i); + // Precision-losing magnitudes throw rather than emit a lossy integer. + expect(() => toBaseUnits(1e30, 18)).toThrow(/precision/i); + }); + test('parseDuration parses various formats', () => { expect(parseDuration('30d')).toBe('2592000000'); expect(parseDuration('1h')).toBe('3600000'); @@ -336,6 +349,19 @@ describe('bounty builder', () => { expect(pid(val(buildBounty({ ...p, amount: 101 })).collectionApprovals, 'bounty-accept')) .not.toBe(pid(a, 'bounty-accept')); }); + test('deterministic — whole msg byte-identical with the Date.now expiry window normalized out', () => { + // bounty's `transferTimes.end` is `durationToTimestamp` (Date.now- + // relative), so a bare toEqual would flake; everything else the + // builder controls must be byte-identical across calls. + const p = { amount: 100, denom: 'USDC', verifier: 'bb1v', recipient: 'bb1r', submitter: 'bb1s', ...META }; + const stripTimes = (v: any): any => + Array.isArray(v) + ? v.map(stripTimes) + : v && typeof v === 'object' + ? Object.fromEntries(Object.entries(v).map(([k, val]) => [k, k === 'transferTimes' ? 'WINDOW' : stripTimes(val)])) + : v; + expect(stripTimes(buildBounty(p))).toEqual(stripTimes(buildBounty(p))); + }); test('passes verification with zero violations (any standard)', () => { expectCleanVerification(msg); }); @@ -492,6 +518,29 @@ describe('product-catalog builder', () => { test('passes verification with zero violations', () => { expectCleanVerification(msg); }); + test('maxSupply: present cap emits overallMaxNumTransfers; omitted = unlimited', () => { + const cap = val(buildProductCatalog({ + products: [{ name: 'Capped', price: 1, denom: 'USDC', maxSupply: 50 }], + storeAddress: 'bb1store', ...META + })).collectionApprovals.find((a: any) => a.approvalId === 'product-purchase-1'); + expect(cap.approvalCriteria.maxNumTransfers.overallMaxNumTransfers).toBe('50'); + + const unlimited = val(buildProductCatalog({ + products: [{ name: 'Unl', price: 1, denom: 'USDC' }], + storeAddress: 'bb1store', ...META + })).collectionApprovals.find((a: any) => a.approvalId === 'product-purchase-1'); + expect(unlimited.approvalCriteria.maxNumTransfers.overallMaxNumTransfers).toBe('0'); + }); + test('maxSupply: rejects negative / non-integer instead of silently going unlimited', () => { + expect(() => buildProductCatalog({ + products: [{ name: 'Bad', price: 1, denom: 'USDC', maxSupply: -1 }], + storeAddress: 'bb1store', ...META + })).toThrow(/maxSupply/i); + expect(() => buildProductCatalog({ + products: [{ name: 'Bad', price: 1, denom: 'USDC', maxSupply: 1.5 }], + storeAddress: 'bb1store', ...META + })).toThrow(/maxSupply/i); + }); }); describe('prediction-market builder', () => { @@ -561,6 +610,10 @@ describe('custom-2fa builder', () => { test('throws without a manager address (no creator)', () => { expect(() => buildCustom2FA({ ...META2FA })).toThrow(/requires a manager address/); }); + test('deterministic — identical params produce a byte-identical msg', () => { + const p = { ...META2FA, creator: 'bb1manager' }; + expect(buildCustom2FA(p)).toEqual(buildCustom2FA(p)); + }); test('passes verification with zero violations', () => { expectCleanVerification(msg); }); @@ -628,6 +681,10 @@ describe('quests builder', () => { expect(mc[0].useCreatorAddressAsLeaf).toBe(false); expect(mc[0].challengeTrackerId).toBe('quests-approval'); }); + test('deterministic — identical params produce a byte-identical msg', () => { + const p = { reward: 10, denom: 'BADGE', maxClaims: 100, ...META }; + expect(buildQuests(p)).toEqual(buildQuests(p)); + }); test('passes verification with zero violations', () => { expectCleanVerification(msg); }); @@ -791,6 +848,35 @@ describe('bid builder (delegates to canonical)', () => { }); expect(a).toEqual(canonical); }); + test('collection-wide bid when token-ids omitted (parity with bb nfts bid)', () => { + const cw = buildBid({ address: 'bb1bidder', collectionId: '1', price: 25, denom: 'BADGE' }).value.approval; + const inc = cw.approvalCriteria.predeterminedBalances.incrementedBalances; + expect(inc.allowOverrideWithAnyValidToken).toBe(true); + expect(cw.approvalCriteria.autoDeletionOptions.afterOneUse).toBe(false); + const canonical = buildOrderbookBidApproval({ + address: 'bb1bidder', + tokenId: undefined, + paymentAmount: BigInt(cw.approvalCriteria.coinTransfers[0].coins[0].amount), + paymentDenom: cw.approvalCriteria.coinTransfers[0].coins[0].denom, + tokenAmount: 1n, + transferTimes: cw.transferTimes, + approvalId: cw.approvalId, + maxNumTransfers: 1n + }); + expect(cw).toEqual(canonical); + }); + test('--token-amount flows into the canonical start balance', () => { + const a = buildBid({ address: 'bb1bidder', collectionId: '1', tokenIds: '3', tokenAmount: 5, price: 25, denom: 'BADGE' }).value.approval; + expect(a.approvalCriteria.predeterminedBalances.incrementedBalances.startBalances[0].amount).toBe(5n); + }); + test('accepts ms-since-epoch expiration (parity with bb nfts bid; durationToTimestamp rejected it)', () => { + const a = buildBid({ address: 'bb1bidder', collectionId: '1', tokenIds: '3', price: 25, denom: 'BADGE', expiration: '1798765432000' }).value.approval; + expect(a.transferTimes).toEqual([{ start: 1n, end: 1798765432000n }]); + }); + test('deterministic — byte-identical msg for identical params (fixed ms expiry)', () => { + const p = { address: 'bb1bidder', collectionId: '1', tokenIds: '3', price: 25, denom: 'BADGE', expiration: '1798765432000' }; + expect(buildBid({ ...p })).toEqual(buildBid({ ...p })); + }); }); describe('pm-sell-intent builder (delegates to canonical)', () => { @@ -857,6 +943,13 @@ describe('pm-buy-intent builder (delegates to canonical)', () => { }); expect(a).toEqual(canonical); }); + test('deterministic approval id for identical params', () => { + const p = { address: 'bb1buyer', collectionId: '42', token: 'no' as const, amount: 200, price: 30, denom: 'USDC' }; + const a = buildPmBuyIntent({ ...p }); + const b = buildPmBuyIntent({ ...p }); + expect(a.value.approval.approvalId).toBe(b.value.approval.approvalId); + expect(buildPmBuyIntent({ ...p, price: 31 }).value.approval.approvalId).not.toBe(a.value.approval.approvalId); + }); }); // ── Zero-violations suite: every builder must pass ALL standard checks ─────── @@ -931,6 +1024,12 @@ describe('error handling', () => { expectCleanVerification(msg); }); + test('amount-taking builders reject negative / non-finite amounts at the producer', () => { + expect(() => buildBounty({ amount: -5, denom: 'BADGE', verifier: 'bb1v', recipient: 'bb1r', submitter: 'bb1s', ...META })).toThrow(/non-negative/i); + expect(() => buildCrowdfund({ goal: Infinity, denom: 'USDC', crowdfunder: 'bb1fund', ...META } as any)).toThrow(/finite/i); + expect(() => buildPaymentRequest({ amount: NaN, denom: 'USDC', payer: 'bb1p', recipient: 'bb1r', ...META } as any)).toThrow(/finite/i); + }); + test('buildProductCatalog with empty products: burn-only, well-formed, verifier-clean', () => { const msg = buildProductCatalog({ products: [], storeAddress: 'bb1s', ...META }); expect(msg.value.collectionApprovals.length).toBe(1); diff --git a/packages/bitbadgesjs-sdk/src/core/builders/product-catalog.ts b/packages/bitbadgesjs-sdk/src/core/builders/product-catalog.ts index dcb88647c7..6cf0b45b48 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/product-catalog.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/product-catalog.ts @@ -40,6 +40,25 @@ export interface ProductCatalogParams { image?: string; } +/** A present maxSupply must be a non-negative integer; 0 or omitted = unlimited (documented). */ +function productMaxNumTransfers(maxSupply: number | undefined, approvalId: string) { + if (maxSupply !== undefined && (!Number.isInteger(maxSupply) || maxSupply < 0)) { + throw new Error( + `Invalid product maxSupply "${maxSupply}": must be a non-negative integer (0 or omitted = unlimited).` + ); + } + return maxSupply + ? { + overallMaxNumTransfers: String(maxSupply), + perToAddressMaxNumTransfers: '0', + perFromAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: approvalId, + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + } + : zeroMaxTransfers(); +} + export function buildProductCatalog(params: ProductCatalogParams): any { const { products, storeAddress } = params; @@ -81,16 +100,7 @@ export function buildProductCatalog(params: ProductCatalogParams): any { overrideToWithInitiator: false } ], - maxNumTransfers: product.maxSupply - ? { - overallMaxNumTransfers: String(product.maxSupply), - perToAddressMaxNumTransfers: '0', - perFromAddressMaxNumTransfers: '0', - perInitiatedByAddressMaxNumTransfers: '0', - amountTrackerId: approvalId, - resetTimeIntervals: { startTime: '0', intervalLength: '0' } - } - : zeroMaxTransfers(), + maxNumTransfers: productMaxNumTransfers(product.maxSupply, approvalId), approvalAmounts: zeroAmounts(), overridesFromOutgoingApprovals: true, overridesToIncomingApprovals: true diff --git a/packages/bitbadgesjs-sdk/src/core/builders/shared.ts b/packages/bitbadgesjs-sdk/src/core/builders/shared.ts index 865e9ef6bb..82b6360cee 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/shared.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/shared.ts @@ -64,7 +64,19 @@ export function resolveCoin(symbolOrDenom: string): ResolvedCoin { * Convert a display-unit amount (e.g. 10 USDC) to base units (e.g. 10000000 uusdc). */ export function toBaseUnits(displayAmount: number, decimals: number): string { - return String(Math.round(displayAmount * 10 ** decimals)); + const num = Number(displayAmount); + if (!Number.isFinite(num) || num < 0) { + throw new Error( + `Invalid amount "${displayAmount}": must be a finite, non-negative number.` + ); + } + const base = Math.round(num * 10 ** decimals); + if (!Number.isSafeInteger(base)) { + throw new Error( + `Amount "${displayAmount}" is too large to convert to base units without precision loss.` + ); + } + return String(base); } // ── Duration parsing ───────────────────────────────────────────────────────── @@ -100,6 +112,22 @@ export function durationToTimestamp(input: string): string { return String(Date.now() + ms); } +/** + * Resolve an order-expiry input to an absolute ms-since-epoch timestamp + * (bigint). Accepts the same forms as the CLI's `parseTimeFlag` so a + * builder reached via `bb build *` behaves identically to its end-user + * `bb ` sibling: a pure-digit string ≥10 chars is raw + * ms-since-epoch; anything else is duration shorthand (now + duration); + * an omitted/empty input falls back to `now + defaultMs`. Invalid + * durations throw (producer-side, consistent with the no-band-aid rule). + */ +export function resolveExpiration(input: string | undefined, defaultMs: number): bigint { + const trimmed = (input ?? '').trim(); + if (trimmed.length === 0) return BigInt(Date.now() + defaultMs); + if (/^\d+$/.test(trimmed) && trimmed.length >= 10) return BigInt(trimmed); + return BigInt(Date.now()) + BigInt(parseDuration(trimmed)); +} + // ── Unique ID generation ───────────────────────────────────────────────────── export function uniqueId(prefix?: string): string { diff --git a/packages/bitbadgesjs-sdk/src/core/subscriptions.spec.ts b/packages/bitbadgesjs-sdk/src/core/subscriptions.spec.ts index 213e9752ff..3070720b32 100644 --- a/packages/bitbadgesjs-sdk/src/core/subscriptions.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/subscriptions.spec.ts @@ -21,6 +21,8 @@ import { } from './subscriptions.js'; import { GO_MAX_UINT_64 } from '../common/math.js'; import { UintRangeArray } from './uintRanges.js'; +import { buildSubscription } from './builders/subscription.js'; +import { normalizeForReview } from './review-normalize.js'; // ---- fixture helpers ----------------------------------------------------- @@ -786,3 +788,40 @@ describe('userRecurringApproval', () => { expect((result.approvalCriteria as any).requireToEqualsInitiatedBy).toBeUndefined(); }); }); + +// =========================================================================== +// Producer ↔ recognizer drift guard (ticket 0425) +// +// `bb build subscription` is the SOLE producer of subscription-faucet +// collections; `isSubscriptionFaucetApproval` / +// `doesCollectionFollowSubscriptionProtocol` are the consumers behind +// `bb subscriptions status|claim|charge-due`. The rest of this file only +// tests the recognizer against hand-built fixtures — so the two 25+-field +// shapes can silently drift apart. These tests run the real +// `buildSubscription` output through the same bigint normalization the +// indexer/verifier uses and assert the recognizer still accepts it. +// =========================================================================== + +const SUB_META = { name: 'Sub', description: 'A subscription collection for the drift guard.', image: 'ipfs://sub' }; + +describe('buildSubscription is recognized by isSubscriptionFaucetApproval (0425 drift guard)', () => { + it('single-tier built collection round-trips through the recognizer', () => { + const built = normalizeForReview( + buildSubscription({ interval: 'monthly', price: 10, denom: 'USDC', recipient: 'bb1test', ...SUB_META }) + ); + const faucets = built.collectionApprovals.filter((a: any) => a.fromListId === 'Mint'); + expect(faucets.length).toBe(1); + for (const a of faucets) expect(isSubscriptionFaucetApproval(a)).toBe(true); + expect(doesCollectionFollowSubscriptionProtocol(built as any)).toBe(true); + }); + + it('multi-tier built collection round-trips through the recognizer', () => { + const built = normalizeForReview( + buildSubscription({ interval: 'daily', price: 5, denom: 'BADGE', recipient: 'bb1r', tiers: 3, ...SUB_META }) + ); + const faucets = built.collectionApprovals.filter((a: any) => a.fromListId === 'Mint'); + expect(faucets.length).toBe(3); + for (const a of faucets) expect(isSubscriptionFaucetApproval(a)).toBe(true); + expect(doesCollectionFollowSubscriptionProtocol(built as any)).toBe(true); + }); +});