Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/bitbadgesjs-sdk/src/cli/commands/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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 }[] = [];
Expand Down
7 changes: 4 additions & 3 deletions packages/bitbadgesjs-sdk/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,16 +786,17 @@ sharedOpts(
.description('Create a marketplace bid (user incoming approval)')
.requiredOption('--address <address>', 'Bidder address (bb1...)')
.requiredOption('--collection-id <id>', 'Collection ID to bid on')
.requiredOption('--token-ids <range>', 'Token ID range (e.g. "1-5" or "1")')
.option('--token-ids <id>', '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 <n>', 'Number of tokens (default 1)', '1')
.requiredOption('--price <n>', 'Bid price (display units)')
.requiredOption('--denom <symbol|denom>', 'Price coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)')
.option('--expiration <duration>', 'Bid duration', '7d')
.option('--expiration <when>', '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(
Expand Down
8 changes: 2 additions & 6 deletions packages/bitbadgesjs-sdk/src/cli/commands/custom-2fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 3 additions & 6 deletions packages/bitbadgesjs-sdk/src/cli/commands/dynamic-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, '<addresses> argument'));
const addresses = resolveRecipientList(rawAddresses, '<addresses> argument');
if (addresses.length === 0) fail(2, 'at least one address required');
const messages = addresses.map((address) => ({
typeUrl: '/tokenization.MsgSetDynamicStoreValue',
Expand Down
3 changes: 2 additions & 1 deletion packages/bitbadgesjs-sdk/src/cli/commands/pairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [string, string, string]> = [
['top-gainers', '/assetPairs/topGainers', 'Top-gaining asset pairs in the last 24h.'],
Expand Down Expand Up @@ -95,7 +96,7 @@ export function registerPairs(parent: Command): void {
.argument('<denoms...>', '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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/bitbadgesjs-sdk/src/cli/commands/pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -108,7 +109,7 @@ export function registerPools(parent: Command): void {
.argument('<pool-ids...>', '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);
Expand Down
5 changes: 1 addition & 4 deletions packages/bitbadgesjs-sdk/src/cli/commands/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<any> {
const network = opts.testnet ? 'testnet' : opts.local ? 'local' : 'mainnet';
const apiKey = resolveApiKey(opts.apiKey, network);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 42 additions & 1 deletion packages/bitbadgesjs-sdk/src/cli/utils/address.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
});
});
14 changes: 14 additions & 0 deletions packages/bitbadgesjs-sdk/src/cli/utils/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
30 changes: 30 additions & 0 deletions packages/bitbadgesjs-sdk/src/cli/utils/csv-options.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
15 changes: 15 additions & 0 deletions packages/bitbadgesjs-sdk/src/cli/utils/csv-options.ts
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 16 additions & 10 deletions packages/bitbadgesjs-sdk/src/core/builders/bid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,54 @@
*
* @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]);
}

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
Expand Down
Loading
Loading