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);
+ });
+});