From b42ed40ee531f36b78ff0baced571d87a5848fc4 Mon Sep 17 00:00:00 2001 From: Trevor Miller Date: Mon, 22 Jun 2026 09:14:45 -0500 Subject: [PATCH 1/2] feat: re-add the Crowdfund standard (SDK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert of eb87fe7750 (#248). The standard was removed because the chain's inclusive 0.1% protocol fee taxed the mint-escrow deposit leg, so a fully-funded crowdfund could never withdraw/refund (funds stuck). The chain is moving to an additive protocol-fee model in v33 (recipient/escrow receives the full quoted amount, fee charged on top), which unblocks crowdfund withdraw/refund — so the standard is restored. Restores core/crowdfunds.ts + builders/crowdfund.ts, builder preset, CLI `crowdfunds` command surface, agent skill, verifier, standards-info, design-decision + review-ux registry entries, and all crowdfund specs. Clean revert against current main (no conflicts). Typecheck clean; full unit suite 3097/3097 green; crowdfund CLI integration specs 13/13 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/api-indexer/standards-info.ts | 15 + .../src/api-indexer/verify-standards.ts | 33 ++ .../builder/agent/tokenTypeInference.spec.ts | 1 + .../src/builder/agent/tokenTypeInference.ts | 1 + .../src/builder/presets/crowdfund.ts | 288 ++++++++++++ .../src/builder/presets/index.ts | 2 + .../builder/resources/skillInstructions.ts | 237 ++++++++++ .../src/builder/tools/registry.ts | 2 +- .../src/cli/commands/build.spec.ts | 4 +- .../bitbadgesjs-sdk/src/cli/commands/build.ts | 23 +- .../src/cli/commands/crowdfunds.spec.ts | 60 +++ .../src/cli/commands/crowdfunds.ts | 288 ++++++++++++ packages/bitbadgesjs-sdk/src/cli/index.ts | 3 + .../src/cli/integration/cli-core.spec.ts | 2 +- .../src/cli/integration/cli-misc.spec.ts | 2 +- .../integration/crowdfund-terminal.spec.ts | 138 ++++++ .../src/cli/integration/crowdfunds.spec.ts | 215 +++++++++ .../src/cli/utils/collection-options.spec.ts | 4 +- .../src/core/builders/builders.spec.ts | 59 +++ .../src/core/builders/crowdfund.ts | 328 +++++++++++++ .../src/core/builders/index.ts | 1 + .../src/core/cross-standard-rejection.spec.ts | 12 + .../src/core/crowdfunds.spec.ts | 362 +++++++++++++++ .../bitbadgesjs-sdk/src/core/crowdfunds.ts | 430 ++++++++++++++++++ .../src/core/design-decisions/standards.ts | 2 + packages/bitbadgesjs-sdk/src/core/index.ts | 1 + .../src/core/review-ux/metadata.ts | 7 +- .../src/core/review-ux/skills.ts | 10 +- 28 files changed, 2520 insertions(+), 10 deletions(-) create mode 100644 packages/bitbadgesjs-sdk/src/builder/presets/crowdfund.ts create mode 100644 packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.spec.ts create mode 100644 packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts create mode 100644 packages/bitbadgesjs-sdk/src/cli/integration/crowdfund-terminal.spec.ts create mode 100644 packages/bitbadgesjs-sdk/src/cli/integration/crowdfunds.spec.ts create mode 100644 packages/bitbadgesjs-sdk/src/core/builders/crowdfund.ts create mode 100644 packages/bitbadgesjs-sdk/src/core/crowdfunds.spec.ts create mode 100644 packages/bitbadgesjs-sdk/src/core/crowdfunds.ts diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/standards-info.ts b/packages/bitbadgesjs-sdk/src/api-indexer/standards-info.ts index 55a24fccf4..ca6dac40ec 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/standards-info.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/standards-info.ts @@ -34,6 +34,20 @@ export interface iPaymentRequestInfo { status: 'pending' | 'paid' | 'denied' | 'expired'; } +/** + * Core details for the Crowdfund standard. + * + * `funded` is derived from the success tracker (the on-chain handler having + * run). `expired` covers any post-deadline state where success hasn't + * executed — without an escrow read we can't distinguish "goal met but not + * yet withdrawn" from "goal not met". + * + * @category Standards Info + */ +export interface iCrowdfundInfo { + status: 'active' | 'expired' | 'funded'; +} + /** * Core details for the Auction standard. * @@ -66,6 +80,7 @@ export interface iPredictionMarketInfo { export interface iStandardsInfo { Bounty?: iBountyInfo; PaymentRequest?: iPaymentRequestInfo; + Crowdfund?: iCrowdfundInfo; Auction?: iAuctionInfo; 'Prediction Market'?: iPredictionMarketInfo; } diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts b/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts index c13d81ac8c..0e13d7f7ca 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts @@ -855,6 +855,37 @@ function verifyBounty(value: any): StandardViolation[] { return violations; } +// ============================================================ +// Crowdfund Validator +// ============================================================ + +function verifyCrowdfund(value: any): StandardViolation[] { + const violations: StandardViolation[] = []; + const std = 'Crowdfund'; + const approvals = getApprovals(value); + + // Must have 2 token IDs (refund + progress) + const tokenIds = value.validTokenIds; + if (!Array.isArray(tokenIds) || tokenIds.length !== 1 || tokenIds[0]?.start !== 1n || tokenIds[0]?.end !== 2n) { + violations.push({ standard: std, field: 'validTokenIds', message: 'Crowdfund collections MUST have validTokenIds = [{ start: "1", end: "2" }] (refund + progress tokens).' }); + } + + // Must have at least 4 approvals + if (approvals.length < 4) { + violations.push({ standard: std, field: 'collectionApprovals', message: `Crowdfund requires at least 4 approvals (deposit-refund, deposit-progress, success, refund). Found ${approvals.length}.` }); + } + + // Check mint approvals have overrides + for (const a of getMintApprovals(value)) { + const ac = a.approvalCriteria || {}; + if (ac.overridesFromOutgoingApprovals !== true && ac.overridesFromOutgoingApprovals !== 'true') { + violations.push({ standard: std, field: `collectionApprovals[${a.approvalId}].overridesFromOutgoingApprovals`, message: `Mint approval "${a.approvalId}" MUST have overridesFromOutgoingApprovals: true.` }); + } + } + + return violations; +} + // ============================================================ // Auction Validator // ============================================================ @@ -1011,6 +1042,7 @@ const STANDARD_VALIDATORS: Record StandardViolation[]> = 'Non-Transferable': verifyNonTransferable, Bounty: verifyBounty, PaymentRequest: verifyPaymentRequest, + Crowdfund: verifyCrowdfund, Auction: verifyAuction, Products: verifyProducts, 'Prediction Market': verifyPredictionMarket, @@ -1041,6 +1073,7 @@ const STANDARD_ALIASES: Record = { PaymentRequest: 'PaymentRequest', 'Payment Request': 'PaymentRequest', Invoice: 'PaymentRequest', + Crowdfund: 'Crowdfund', Auction: 'Auction', Products: 'Products', 'Product Catalog': 'Products', diff --git a/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.spec.ts b/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.spec.ts index a30e37d82f..5e1428e9a7 100644 --- a/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.spec.ts +++ b/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.spec.ts @@ -66,6 +66,7 @@ describe('tokenTypeInference catalog helpers', () => { 'quest', 'bounty', 'payment-request', + 'crowdfund', 'auction', 'product-catalog', 'prediction-market' diff --git a/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.ts b/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.ts index baf1e80809..df0111c85a 100644 --- a/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.ts +++ b/packages/bitbadgesjs-sdk/src/builder/agent/tokenTypeInference.ts @@ -96,6 +96,7 @@ export const STANDARD_TO_TOKEN_TYPE: ReadonlyArray<{ standard: string; skillId: { standard: 'Quest', skillId: 'quest' }, { standard: 'Bounty', skillId: 'bounty' }, { standard: 'PaymentRequest', skillId: 'payment-request' }, + { standard: 'Crowdfund', skillId: 'crowdfund' }, { standard: 'Auction', skillId: 'auction' }, { standard: 'Products', skillId: 'product-catalog' }, { standard: 'Prediction Market', skillId: 'prediction-market' }, diff --git a/packages/bitbadgesjs-sdk/src/builder/presets/crowdfund.ts b/packages/bitbadgesjs-sdk/src/builder/presets/crowdfund.ts new file mode 100644 index 0000000000..f44eca9f79 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/builder/presets/crowdfund.ts @@ -0,0 +1,288 @@ +/** + * Crowdfund presets — 4 approvals: + * - deposit-refund: contributor pays coins → receives Token 1 (refund token) + * - deposit-progress: paired — mints Token 2 to crowdfunder (no coinTransfer) + * - success-withdraw: crowdfunder withdraws escrow AFTER deadline IF goal met + * - refund: contributor burns Token 1 → receives refund AFTER deadline IF goal NOT met + * + * Token IDs: 1 = Refund token (contributor), 2 = Progress token (crowdfunder). + * Goal is tracked via mustOwnTokens against collectionId "0" (self-reference). + * All 4 approvals use allowAmountScaling:true so the contributor can choose deposit size. + */ + +import { z } from 'zod'; +import type { Preset, RenderedApproval } from './types.js'; + +const MAX_UINT64 = '18446744073709551615'; +const FOREVER = [{ start: '1', end: MAX_UINT64 }]; +const TOKEN_1 = [{ start: '1', end: '1' }]; +const TOKEN_2 = [{ start: '2', end: '2' }]; +const BURN_ADDRESS = 'bb1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7gvmv'; + +function orderCalc(): Record { + return { + useOverallNumTransfers: true, + usePerToAddressNumTransfers: false, + usePerFromAddressNumTransfers: false, + usePerInitiatedByAddressNumTransfers: false, + useMerkleChallengeLeafIndex: false, + challengeTrackerId: '' + }; +} + +function scaledBalances(startBalances: Array>): Record { + return { + startBalances, + allowAmountScaling: true, + maxScalingMultiplier: MAX_UINT64, + incrementTokenIdsBy: '0', + incrementOwnershipTimesBy: '0', + durationFromTimestamp: '0', + allowOverrideTimestamp: false, + recurringOwnershipTimes: { startTime: '0', intervalLength: '0', chargePeriodLength: '0' }, + allowOverrideWithAnyValidToken: false + }; +} + +const DepositParams = z.object({ + denom: z.string().describe('Deposit denom (e.g. "ibc/..." hash for USDC).'), + deadlineMs: z.string().describe('Unix ms timestamp when crowdfund closes.'), + crowdfunderAddress: z.string().describe('bb1... address of the crowdfunder (receives Token 2 / withdraws funds).') +}); +type DepositParams = z.infer; + +function renderDepositRefund(p: DepositParams): RenderedApproval { + return { + fromListId: 'Mint', + toListId: 'All', + initiatedByListId: 'All', + transferTimes: [{ start: '1', end: p.deadlineMs }], + tokenIds: TOKEN_1, + ownershipTimes: FOREVER, + approvalId: 'deposit-refund', + uri: '', + customData: '', + version: '0', + approvalCriteria: { + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: true, + requireToEqualsInitiatedBy: true, + coinTransfers: [ + { + to: 'Mint', + overrideFromWithApproverAddress: false, + overrideToWithInitiator: false, + coins: [{ amount: '1', denom: p.denom }] + } + ], + predeterminedBalances: { + manualBalances: [], + incrementedBalances: scaledBalances([ + { amount: '1', tokenIds: TOKEN_1, ownershipTimes: FOREVER } + ]), + orderCalculationMethod: orderCalc() + }, + maxNumTransfers: { + overallMaxNumTransfers: '0', + perFromAddressMaxNumTransfers: '0', + perToAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'deposit-refund', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + merkleChallenges: [], + mustOwnTokens: [], + votingChallenges: [] + } + }; +} + +function renderDepositProgress(p: DepositParams): RenderedApproval { + return { + fromListId: 'Mint', + toListId: p.crowdfunderAddress, + initiatedByListId: 'All', + transferTimes: [{ start: '1', end: p.deadlineMs }], + tokenIds: TOKEN_2, + ownershipTimes: FOREVER, + approvalId: 'deposit-progress', + uri: '', + customData: '', + version: '0', + approvalCriteria: { + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: true, + coinTransfers: [], + predeterminedBalances: { + manualBalances: [], + incrementedBalances: scaledBalances([ + { amount: '1', tokenIds: TOKEN_2, ownershipTimes: FOREVER } + ]), + orderCalculationMethod: orderCalc() + }, + maxNumTransfers: { + overallMaxNumTransfers: '0', + perFromAddressMaxNumTransfers: '0', + perToAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'deposit-progress', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + merkleChallenges: [], + mustOwnTokens: [], + votingChallenges: [] + } + }; +} + +const SettlementParams = z.object({ + denom: z.string().describe('Deposit denom.'), + deadlineMs: z.string().describe('Unix ms timestamp when crowdfund closes.'), + crowdfunderAddress: z.string().describe('bb1... crowdfunder address (checked in mustOwnTokens).'), + goalAmount: z.string().describe('Goal in BASE units of the denom — the crowdfunder needs >= this much of Token 2 to withdraw; contributors need the crowdfunder to have < this for refunds.') +}); +type SettlementParams = z.infer; + +function renderSuccess(p: SettlementParams): RenderedApproval { + return { + fromListId: 'Mint', + toListId: BURN_ADDRESS, + initiatedByListId: p.crowdfunderAddress, + transferTimes: [{ start: (BigInt(p.deadlineMs) + 1n).toString(), end: MAX_UINT64 }], + tokenIds: TOKEN_1, + ownershipTimes: FOREVER, + approvalId: 'success-withdraw', + uri: '', + customData: '', + version: '0', + approvalCriteria: { + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: true, + coinTransfers: [ + { + to: p.crowdfunderAddress, + overrideFromWithApproverAddress: true, + overrideToWithInitiator: false, + coins: [{ amount: '1', denom: p.denom }] + } + ], + mustOwnTokens: [ + { + collectionId: '0', + tokenIds: TOKEN_2, + amountRange: { start: p.goalAmount, end: MAX_UINT64 }, + ownershipCheckParty: p.crowdfunderAddress + } + ], + predeterminedBalances: { + manualBalances: [], + incrementedBalances: scaledBalances([ + { amount: '1', tokenIds: TOKEN_1, ownershipTimes: FOREVER } + ]), + orderCalculationMethod: orderCalc() + }, + maxNumTransfers: { + overallMaxNumTransfers: '1', + perFromAddressMaxNumTransfers: '0', + perToAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'success-withdraw', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + merkleChallenges: [], + votingChallenges: [] + } + }; +} + +function renderRefund(p: SettlementParams): RenderedApproval { + const goalMinusOne = (BigInt(p.goalAmount) - 1n).toString(); + return { + fromListId: '!Mint', + toListId: BURN_ADDRESS, + initiatedByListId: 'All', + transferTimes: [{ start: (BigInt(p.deadlineMs) + 1n).toString(), end: MAX_UINT64 }], + tokenIds: TOKEN_1, + ownershipTimes: FOREVER, + approvalId: 'refund', + uri: '', + customData: '', + version: '0', + approvalCriteria: { + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: true, + coinTransfers: [ + { + to: '', + overrideFromWithApproverAddress: true, + overrideToWithInitiator: true, + coins: [{ amount: '1', denom: p.denom }] + } + ], + mustOwnTokens: [ + { + collectionId: '0', + tokenIds: TOKEN_2, + amountRange: { start: '0', end: goalMinusOne }, + ownershipCheckParty: p.crowdfunderAddress + } + ], + predeterminedBalances: { + manualBalances: [], + incrementedBalances: scaledBalances([ + { amount: '1', tokenIds: TOKEN_1, ownershipTimes: FOREVER } + ]), + orderCalculationMethod: orderCalc() + }, + maxNumTransfers: { + overallMaxNumTransfers: MAX_UINT64, + perFromAddressMaxNumTransfers: '0', + perToAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'refund', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + merkleChallenges: [], + votingChallenges: [] + } + }; +} + +export const CROWDFUND_PRESETS: Preset[] = [ + { + presetId: 'crowdfund.deposit-refund', + skillId: 'crowdfund', + name: 'Crowdfund — deposit-refund (contributor pays → gets refund token)', + description: + 'Contributor pays coins and receives Token 1 (refund token, one per unit deposited). allowAmountScaling:true lets the contributor choose their deposit size. requireToEqualsInitiatedBy:true ensures the contributor receives their own refund token. Coins go to "Mint" (auto-resolves to escrow).', + paramsSchema: DepositParams, + render: renderDepositRefund + }, + { + presetId: 'crowdfund.deposit-progress', + skillId: 'crowdfund', + name: 'Crowdfund — deposit-progress (paired: mints progress token to crowdfunder)', + description: + 'Paired with deposit-refund. Mints Token 2 (progress token) to the crowdfunder for each deposit — this is what mustOwnTokens checks against the goal. toListId = crowdfunder address. No coinTransfers.', + paramsSchema: DepositParams, + render: renderDepositProgress + }, + { + presetId: 'crowdfund.success-withdraw', + skillId: 'crowdfund', + name: 'Crowdfund — success/withdraw (crowdfunder takes funds if goal met)', + description: + 'After the deadline, if the crowdfunder owns >= goal of Token 2, they can withdraw escrow to themselves. mustOwnTokens with collectionId:"0" = self-reference. transferTimes begin at deadlineMs+1.', + paramsSchema: SettlementParams, + render: renderSuccess + }, + { + presetId: 'crowdfund.refund', + skillId: 'crowdfund', + name: 'Crowdfund — refund (contributors redeem if goal NOT met)', + description: + 'After the deadline, if the crowdfunder owns LESS than goal, contributors can burn their Token 1 to receive a proportional refund. overrideFromWithApproverAddress + overrideToWithInitiator route escrow → contributor. transferTimes begin at deadlineMs+1.', + paramsSchema: SettlementParams, + render: renderRefund + } +]; diff --git a/packages/bitbadgesjs-sdk/src/builder/presets/index.ts b/packages/bitbadgesjs-sdk/src/builder/presets/index.ts index 3accc9e2ad..e24e5bbd8a 100644 --- a/packages/bitbadgesjs-sdk/src/builder/presets/index.ts +++ b/packages/bitbadgesjs-sdk/src/builder/presets/index.ts @@ -24,6 +24,7 @@ import { TRADABLE_PRESETS } from './tradable.js'; import { AUCTION_PRESETS } from './auction.js'; import { PRODUCTS_PRESETS } from './products.js'; import { PAYMENT_PROTOCOL_PRESETS } from './payment-protocol.js'; +import { CROWDFUND_PRESETS } from './crowdfund.js'; /** * Aggregate every preset in the SDK. Add a new skill's presets by @@ -42,6 +43,7 @@ const ALL_PRESETS: Preset[] = [ ...AUCTION_PRESETS, ...PRODUCTS_PRESETS, ...PAYMENT_PROTOCOL_PRESETS, + ...CROWDFUND_PRESETS ]; // Duplicate-id guard — surfaces at import time, not runtime, so preset diff --git a/packages/bitbadgesjs-sdk/src/builder/resources/skillInstructions.ts b/packages/bitbadgesjs-sdk/src/builder/resources/skillInstructions.ts index 2b73c992b5..0a398fc2b1 100644 --- a/packages/bitbadgesjs-sdk/src/builder/resources/skillInstructions.ts +++ b/packages/bitbadgesjs-sdk/src/builder/resources/skillInstructions.ts @@ -3055,6 +3055,243 @@ All permissions MUST be frozen (same set as Bounty). ## Relationship to the Invoices Standard The existing \`Invoices\` standard validates a single payer-as-initiator approval — useful as a building block, but it has no deny branch and no targeted-payer scoping. PaymentRequest is a more constrained, agent-payments-specific subset: same payment direction (initiator → address), but with an explicit pay+deny pair so the payer's "no" is captured on-chain rather than being indistinguishable from "hasn't acted yet". Consumers that want any payer-initiated payment can match \`Invoices\`; consumers that want the agent-payments artifact specifically should match \`PaymentRequest\`.` + }, + { + id: 'crowdfund', + name: 'Crowdfund', + category: 'token-type', + description: 'On-chain crowdfunding with goal tracking via mustOwnTokens. Contributors deposit funds, receive refund tokens. Crowdfunder withdraws if goal met, contributors refund if not.', + summary: `Required standards: ["Crowdfund"] + +- 2 token IDs: Token 1 = Refund token (contributor holds), Token 2 = Progress token (crowdfunder accumulates) +- 4 collection-level approvals: deposit-refund, deposit-progress, success (withdraw), refund +- Contributors deposit coins → receive Token 1 (refund token). Paired approval mints Token 2 to crowdfunder (progress tracking). +- Success: crowdfunder withdraws if mustOwnTokens confirms they hold >= goal of Token 2 (collectionId: 0 = self-reference) +- Refund: after deadline, contributors burn Token 1 → escrow pays them back (only if goal NOT met via mustOwnTokens check) +- allowAmountScaling: true on ALL 4 approvals (contributors choose deposit size, everything scales proportionally) +- maxScalingMultiplier: MAX_UINT for unrestricted scaling +- Deposit coinTransfer.to = "Mint" (auto-resolves to escrow) +- requireToEqualsInitiatedBy: true on deposit-refund (contributor receives their own refund token) +- invariants: \\\`noForcefulPostMintTransfers: true\\\` — the refund approval (non-mint) MUST NOT set \\\`overridesFromOutgoingApprovals\\\` or \\\`overridesToIncomingApprovals\\\` (both must be false or omitted). It relies on \\\`defaultBalances.autoApproveSelfInitiatedOutgoingTransfers: true\\\` for the outgoing side and on the burn destination for the incoming side. The deposit-refund / deposit-progress / success approvals ARE Mint-side and keep \\\`overridesFromOutgoingApprovals: true\\\` as the chain requires, with \\\`overridesToIncomingApprovals: false\\\` +- All permissions frozen after creation +- DON'T use votingChallenges — goal tracking is via mustOwnTokens, not voting +- DON'T forget allowAmountScaling on ALL 4 approvals +- DON'T set overrideFromWithApproverAddress on deposit (contributor pays, not escrow) +- DO set overrideFromWithApproverAddress: true on success and refund (escrow pays out) +- DO set overrideToWithInitiator: true on refund (contributor receives their own refund) +- DO use collectionId: "0" in mustOwnTokens for self-reference`, + instructions: `## Crowdfund Configuration + +### Mental Model + +On-chain crowdfunding with automatic goal tracking. Contributors deposit coins and receive refund tokens. A progress token tracks total raised. If the goal is met, the crowdfunder withdraws all funds. If not, contributors burn their refund tokens to get deposits back. + +### Collection Structure + +- Token ID 1 = Refund token (contributor holds — burn to refund) +- Token ID 2 = Progress token (crowdfunder accumulates — tracks total raised) +- Standard: "Crowdfund" +- validTokenIds: [{ start: "1", end: "2" }] +- invariants: { noCustomOwnershipTimes: true } +- All permissions frozen after creation + +### 4 Required Approvals + +### Preferred path: presets (four short tool calls) + +\`\`\` +add_preset_approval({ presetId: "crowdfund.deposit-refund", params: { denom, deadlineMs, crowdfunderAddress } }) +add_preset_approval({ presetId: "crowdfund.deposit-progress", params: { denom, deadlineMs, crowdfunderAddress } }) +add_preset_approval({ presetId: "crowdfund.success-withdraw", params: { denom, deadlineMs, crowdfunderAddress, goalAmount } }) +add_preset_approval({ presetId: "crowdfund.refund", params: { denom, deadlineMs, crowdfunderAddress, goalAmount } }) +\`\`\` + +#### 1. Deposit-Refund (contributor pays coins → receives Token 1) + +\`\`\`json +{ + "approvalId": "deposit-refund", + "fromListId": "Mint", + "toListId": "All", + "initiatedByListId": "All", + "tokenIds": [{ "start": "1", "end": "1" }], + "transferTimes": [{ "start": "1", "end": "" }], + "approvalCriteria": { + "overridesFromOutgoingApprovals": true, + "overridesToIncomingApprovals": true, + "requireToEqualsInitiatedBy": true, + "coinTransfers": [{ + "to": "Mint", + "overrideFromWithApproverAddress": false, + "overrideToWithInitiator": false, + "coins": [{ "amount": "1", "denom": "" }] + }], + "predeterminedBalances": { + "incrementedBalances": { + "startBalances": [{ "amount": "1", "tokenIds": [{ "start": "1", "end": "1" }], "ownershipTimes": [{ "start": "1", "end": "18446744073709551615" }] }], + "allowAmountScaling": true, + "maxScalingMultiplier": "18446744073709551615", + "incrementTokenIdsBy": "0", + "incrementOwnershipTimesBy": "0", + "durationFromTimestamp": "0", + "allowOverrideTimestamp": false + }, + "orderCalculationMethod": { "useOverallNumTransfers": true } + }, + "maxNumTransfers": { "overallMaxNumTransfers": "0" } + } +} +\`\`\` + +> **CRITICAL:** \`requireToEqualsInitiatedBy: true\` ensures the contributor receives their own refund token. \`allowAmountScaling: true\` lets contributors choose their deposit size — the coin payment and token amount scale together. + +#### 2. Deposit-Progress (paired: mints Token 2 to crowdfunder, no coinTransfer) + +\`\`\`json +{ + "approvalId": "deposit-progress", + "fromListId": "Mint", + "toListId": "", + "initiatedByListId": "All", + "tokenIds": [{ "start": "2", "end": "2" }], + "transferTimes": [{ "start": "1", "end": "" }], + "approvalCriteria": { + "overridesFromOutgoingApprovals": true, + "overridesToIncomingApprovals": true, + "coinTransfers": [], + "predeterminedBalances": { + "incrementedBalances": { + "startBalances": [{ "amount": "1", "tokenIds": [{ "start": "2", "end": "2" }], "ownershipTimes": [{ "start": "1", "end": "18446744073709551615" }] }], + "allowAmountScaling": true, + "maxScalingMultiplier": "18446744073709551615", + "incrementTokenIdsBy": "0", + "incrementOwnershipTimesBy": "0", + "durationFromTimestamp": "0", + "allowOverrideTimestamp": false + }, + "orderCalculationMethod": { "useOverallNumTransfers": true } + }, + "maxNumTransfers": { "overallMaxNumTransfers": "0" } + } +} +\`\`\` + +> **toListId** is the crowdfunder's specific address (not "All"). No coinTransfer — this is the paired counterpart to deposit-refund. + +#### 3. Success / Withdraw (crowdfunder withdraws if goal met) + +\`\`\`json +{ + "approvalId": "success-withdraw", + "fromListId": "Mint", + "toListId": "", + "initiatedByListId": "", + "tokenIds": [{ "start": "1", "end": "1" }], + "transferTimes": [{ "start": "", "end": "18446744073709551615" }], + "approvalCriteria": { + "overridesFromOutgoingApprovals": true, + "overridesToIncomingApprovals": true, + "coinTransfers": [{ + "to": "", + "overrideFromWithApproverAddress": true, + "overrideToWithInitiator": false, + "coins": [{ "amount": "1", "denom": "" }] + }], + "mustOwnTokens": [{ + "collectionId": "0", + "tokenIds": [{ "start": "2", "end": "2" }], + "amountRange": { "start": "", "end": "18446744073709551615" }, + "ownershipCheckParty": "" + }], + "predeterminedBalances": { + "incrementedBalances": { + "startBalances": [{ "amount": "1", "tokenIds": [{ "start": "1", "end": "1" }], "ownershipTimes": [{ "start": "1", "end": "18446744073709551615" }] }], + "allowAmountScaling": true, + "maxScalingMultiplier": "18446744073709551615", + "incrementTokenIdsBy": "0", + "incrementOwnershipTimesBy": "0", + "durationFromTimestamp": "0", + "allowOverrideTimestamp": false + }, + "orderCalculationMethod": { "useOverallNumTransfers": true } + }, + "maxNumTransfers": { "overallMaxNumTransfers": "1" } + } +} +\`\`\` + +> **mustOwnTokens with collectionId: "0"** = self-reference. Checks that the crowdfunder owns >= goal amount of Token 2 (progress token). Only available after deadline. + +#### 4. Refund (contributor burns Token 1 → gets deposit back, only if goal NOT met) + +\`\`\`json +{ + "approvalId": "refund", + "fromListId": "!Mint", + "toListId": "", + "initiatedByListId": "All", + "tokenIds": [{ "start": "1", "end": "1" }], + "transferTimes": [{ "start": "", "end": "18446744073709551615" }], + "approvalCriteria": { + "overridesFromOutgoingApprovals": true, + "overridesToIncomingApprovals": true, + "coinTransfers": [{ + "to": "", + "overrideFromWithApproverAddress": true, + "overrideToWithInitiator": true, + "coins": [{ "amount": "1", "denom": "" }] + }], + "mustOwnTokens": [{ + "collectionId": "0", + "tokenIds": [{ "start": "2", "end": "2" }], + "amountRange": { "start": "0", "end": "" }, + "ownershipCheckParty": "" + }], + "predeterminedBalances": { + "incrementedBalances": { + "startBalances": [{ "amount": "1", "tokenIds": [{ "start": "1", "end": "1" }], "ownershipTimes": [{ "start": "1", "end": "18446744073709551615" }] }], + "allowAmountScaling": true, + "maxScalingMultiplier": "18446744073709551615", + "incrementTokenIdsBy": "0", + "incrementOwnershipTimesBy": "0", + "durationFromTimestamp": "0", + "allowOverrideTimestamp": false + }, + "orderCalculationMethod": { "useOverallNumTransfers": true } + }, + "maxNumTransfers": { + "overallMaxNumTransfers": "18446744073709551615" + } + } +} +\`\`\` + +> Refund uses overrideFromWithApproverAddress: true (escrow pays) + overrideToWithInitiator: true (contributor receives). mustOwnTokens checks crowdfunder has LESS than goal of Token 2 (amountRange.end = goal - 1). allowAmountScaling: true so refund scales with deposit size. maxNumTransfers = MAX_UINT (needs non-zero with overrideFromWithApproverAddress). + +### Creation Flow (Tool Calls) + +1. \\\`set_valid_token_ids\\\` — set [{ start: "1", end: "2" }] +2. \\\`set_standards\\\` — set ["Crowdfund"] +3. \\\`set_invariants\\\` — set { noCustomOwnershipTimes: true } +4. \\\`add_approval\\\` x4 — deposit-refund, deposit-progress, success, refund +5. \\\`set_collection_metadata\\\` — name, description, image +6. \\\`set_token_metadata\\\` x2 — Token 1 (Refund), Token 2 (Progress) +7. \\\`set_permissions\\\` — preset "fully-immutable" +8. \\\`validate_transaction\\\` — verify structure +9. \\\`simulate_transaction\\\` — dry run + +### Common Mistakes + +- DON'T forget allowAmountScaling: true on ALL 4 approvals — without it, all deposits are fixed at 1 base unit +- DON'T use votingChallenges — goal tracking uses mustOwnTokens, not voting +- DON'T forget maxScalingMultiplier: MAX_UINT — without it, scaling is capped at 0 (no scaling) +- DON'T set overrideFromWithApproverAddress on deposit-refund or deposit-progress (contributor pays, not escrow) +- DON'T forget requireToEqualsInitiatedBy: true on deposit-refund +- DON'T forget the paired deposit-progress approval — it tracks total raised +- DON'T set collectionId to the actual collection ID in mustOwnTokens — use "0" for self-reference +- DON'T forget that success transferTimes must start AFTER the deadline (deadline + 1) +- DON'T forget that refund mustOwnTokens amountRange.end = goal - 1 (strictly less than goal) +- DON'T set maxNumTransfers = 0 on refund approval — overrideFromWithApproverAddress requires non-zero` }, { id: 'auction', diff --git a/packages/bitbadgesjs-sdk/src/builder/tools/registry.ts b/packages/bitbadgesjs-sdk/src/builder/tools/registry.ts index 5479107980..4b89fd3c8b 100644 --- a/packages/bitbadgesjs-sdk/src/builder/tools/registry.ts +++ b/packages/bitbadgesjs-sdk/src/builder/tools/registry.ts @@ -132,7 +132,7 @@ function entry(tool: any, run: (args: any) => any, formatText?: (result: any) => // `SKILL_INSTRUCTIONS` array at module init — see backlog #0241. A previous // hard-coded list drifted: it advertised two IDs that no longer existed and // omitted six that did, leading agents to request nonexistent skills and miss -// curated ones (auto-mint, prediction-market, bounty, auction, +// curated ones (auto-mint, prediction-market, bounty, crowdfund, auction, // product-catalog). Computing the list here means every future skill addition // appears in the tool description automatically — no manual sync required. const availableSkillIds = getAllSkillInstructions() diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts index 3405b3c739..257dbe9df0 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts @@ -13,7 +13,7 @@ import { buildProductCatalog } from '../../core/builders/product-catalog.js'; describe('buildCommand shape', () => { it('exposes every documented standard preset', () => { - // The 16 verbs that `bb build --help` advertises. If a verb is added + // The 17 verbs that `bb build --help` advertises. If a verb is added // or removed, this test must be updated in lockstep with --help text. const names = buildCommand.commands.map((c) => c.name()).sort(); expect(names).toEqual([ @@ -22,6 +22,7 @@ describe('buildCommand shape', () => { 'bid', 'bounty', 'credit-token', + 'crowdfund', 'custom-2fa', 'intent', 'listing', @@ -61,6 +62,7 @@ describe('buildCommand shape', () => { 'subscription', 'bounty', 'payment-request', + 'crowdfund', 'auction', 'product-catalog', 'prediction-market', diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts index 7a4a7d5f3e..c9914e0b20 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts @@ -109,7 +109,7 @@ async function emit( // the chain to substitute `to` with the initiator at runtime // (correct for quest rewards, bounty payouts to claimants, etc.). // The empty `to` without the override means "refund the creator" - // — typical for deny/expire branches in bounty/etc. where + // — typical for deny/expire branches in bounty/crowdfund/etc. where // the builder couldn't know the creator at build time. const creatorForFallback = data.value.creator; if (Array.isArray(data.value.collectionApprovals) && creatorForFallback) { @@ -544,6 +544,25 @@ sharedOpts( }), opts); }); +sharedOpts( + buildCommand + .command('crowdfund') + .description('Create a crowdfunding collection. Metadata: pass --uri OR --name + --image + --description.') + .requiredOption('--goal ', 'Funding goal (display units)') + .requiredOption('--denom ', 'Coin. BADGE, USDC, … or canonical denom (ubadge, ibc/...)') + .option('--crowdfunder
', 'Who receives funds on success (bb1...)') + .option('--deadline ', 'Deadline duration', '30d') +).action(async (opts) => { + const { buildCrowdfund } = await import('../../core/builders/crowdfund.js'); + if (opts.json) { emit(buildCrowdfund(readJsonInput(opts.json)), opts); return; } + const denom = requireBbDenom(opts.denom, '--denom'); + const crowdfunder = opts.crowdfunder ? requireBb1AddressStrict(opts.crowdfunder, '--crowdfunder') : opts.crowdfunder; + emit(buildCrowdfund({ + goal: Number(opts.goal), denom, crowdfunder, deadline: opts.deadline, + uri: opts.uri, name: opts.name, description: opts.description, image: opts.image, creator: opts.creator + }), opts); +}); + sharedOpts( buildCommand .command('auction') @@ -845,7 +864,7 @@ sharedOpts( const fromAddress = requireBb1AddressStrict(opts.from, '--from'); const toAddress = requireBb1AddressStrict(opts.to, '--to'); // Canonical amount/denom resolution — shared with auctions / - // nfts / intents / prediction-markets (0410). This + // crowdfunds / nfts / intents / prediction-markets (0410). This // replaces a re-rolled branch whose else-path display-converted // even canonical chain denoms, contradicting this command's own // "base units when --denom is a raw chain denom" help text; diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.spec.ts new file mode 100644 index 0000000000..da58377104 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.spec.ts @@ -0,0 +1,60 @@ +/** + * Command-tree shape tests for crowdfunds.ts. Validators + helper builders + * are exercised in core/crowdfunds.spec.ts. + */ + +import { crowdfundsCommand } from './crowdfunds.js'; + +describe('crowdfundsCommand shape', () => { + it('is named `crowdfunds` and exposes `crowdfund` as a backwards-compat alias', () => { + expect(crowdfundsCommand.name()).toBe('crowdfunds'); + expect(crowdfundsCommand.aliases()).toContain('crowdfund'); + }); + + it('exposes the documented subcommand verbs', () => { + const names = crowdfundsCommand.commands.map((c) => c.name()).sort(); + // Per-standard `build` removed in CLI v2 (#0399); use `bb build crowdfund`. + expect(names).toEqual([ + 'contribute', + 'list', + 'refund', + 'show', + 'status', + 'withdraw' + ]); + }); + + it('contribute + refund require --creator + --amount', () => { + for (const verb of ['contribute', 'refund']) { + const cmd = crowdfundsCommand.commands.find((c) => c.name() === verb); + const required = (cmd! as any).options.filter((o: any) => o.required).map((o: any) => o.long); + expect(required).toContain('--creator'); + expect(required).toContain('--amount'); + } + }); + + it('withdraw requires --creator', () => { + const cmd = crowdfundsCommand.commands.find((c) => c.name() === 'withdraw'); + const required = (cmd! as any).options.filter((o: any) => o.required).map((o: any) => o.long); + expect(required).toContain('--creator'); + }); + + it('list exposes --mine + --open filters', () => { + const list = crowdfundsCommand.commands.find((c) => c.name() === 'list'); + const flagNames = (list! as any).options.map((o: any) => o.long); + expect(flagNames).toContain('--mine'); + expect(flagNames).toContain('--open'); + }); + + it('contribute / withdraw / refund / show / status take ', () => { + for (const verb of ['contribute', 'withdraw', 'refund', 'show', 'status']) { + const c = crowdfundsCommand.commands.find((cmd) => cmd.name() === verb); + expect((c! as any)._args[0].name()).toBe('collection-id'); + } + }); + + it('no longer registers a `build` subcommand — use `bb build crowdfund` instead', () => { + const build = crowdfundsCommand.commands.find((c) => c.name() === 'build'); + expect(build).toBeUndefined(); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts new file mode 100644 index 0000000000..8f2c3bb32d --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/crowdfunds.ts @@ -0,0 +1,288 @@ +/** + * `bitbadges-cli crowdfunds` — end-user surface for the Crowdfund standard. + * Mirrors the FE's `CrowdfundView`. + * + * `crowdfund` (singular) is retained as a hidden alias for backwards compat + * with scripts that pre-date the rename. New code / docs should use the + * plural form, which matches every other standards command (`bb auctions`, + * `bb credit-tokens`, `bb prediction-markets`, etc). + */ + +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'; +import { addIndexerOutputOptions as addOutputFlags } from '../utils/indexer-options.js'; +import { + doesCollectionFollowCrowdfundProtocol, + validateCrowdfundCollection, + extractCrowdfundDetails, + deriveCrowdfundStatus, + buildContributeCrowdfundTx, + buildWithdrawCrowdfundTx, + buildRefundCrowdfundMsg +} from '../../core/crowdfunds.js'; +import { BitBadgesCollection } from '../../api-indexer/BitBadgesCollection.js'; +import { BigIntify } from '../../common/string-numbers.js'; + +const BURN_ADDRESS = 'bb1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7gvmv'; + +interface NetworkFlags { testnet?: boolean; local?: boolean; url?: string; apiKey?: string; } +interface OutputFlags { outputFile?: string; condensed?: boolean; } + +// Repointed onto the unified helper (0412) — was a local re-declaration +// of the same --testnet/--local/--url/--api-key surface. Network +// resolution still reads opts.testnet/local/url/apiKey, unchanged. +function addNetworkFlags(cmd: Command): Command { + return addUnifiedNetworkOptions(cmd, { includeNetworkFlag: false, includeMainnetFlag: false }); +} +async function callApi(method: 'GET' | 'POST', path: string, opts: NetworkFlags, body?: unknown): Promise { + const network = opts.testnet ? 'testnet' : opts.local ? 'local' : 'mainnet'; + const apiKey = resolveApiKey(opts.apiKey, network); + const baseUrl = resolveBaseUrl({ testnet: opts.testnet, local: opts.local, baseUrl: opts.url }); + return apiRequest({ method, path, body, apiKey, baseUrl }); +} +async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); +} +function validateOrExit(collection: any, ctx: string): void { + validateCollectionOrExit(collection, ctx, validateCrowdfundCollection, 'Crowdfund'); +} + +async function readRaised(collection: any, opts: NetworkFlags, details: any): Promise { + // Raised = how many token-2 the crowdfunder owns + try { + const balances = await callApi( + 'POST', + `/collection/${encodeURIComponent(collection.collectionId ?? collection._docId)}/balance/${encodeURIComponent(details.crowdfunderAddress)}`, + opts, + {} + ); + const userBalances = balances?.balance?.balances ?? balances?.balances ?? []; + let total = 0n; + for (const b of userBalances) { + for (const r of b.tokenIds ?? []) { + if (BigInt(r.start) <= 2n && BigInt(r.end) >= 2n) total += BigInt(b.amount); + } + } + return total; + } catch { + return 0n; + } +} + +export const crowdfundsCommand = new Command('crowdfunds') + .alias('crowdfund') + .description( + 'End-user surface for the Crowdfund standard — list / show / status / contribute / withdraw / refund. Build new via `bb build crowdfund`.' + ); + +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('list') + .description('Browse Crowdfund collections.') + .option('--mine
', 'Restrict to crowdfunds initiated by this crowdfunder address') + .option('--open', 'Only return active (not funded/expired) crowdfunds', false) + ) +).action(async (opts: NetworkFlags & OutputFlags & { mine?: string; open?: boolean }) => { + try { + const res = await callApi('POST', '/browse', opts, { type: 'collections', category: 'crowdfund' }); + const all: any[] = res?.collections?.crowdfund ?? res?.collections ?? []; + // BigIntify each row before validation — `validateCrowdfundCollection` + // checks tokenId===1n / start===2n etc., which silently fails when + // the indexer returns string token ids. Without this conversion the + // list filter would drop every row. + const normalized = all.map((c: any) => { + try { return new BitBadgesCollection(c).convert(BigIntify); } catch { return c; } + }); + let collections = normalized.filter((c: any) => doesCollectionFollowCrowdfundProtocol(c)); + if (opts.mine) { + const bb1 = requireBb1Address(opts.mine, '--mine'); + collections = collections.filter((c: any) => extractCrowdfundDetails(c.collectionApprovals)?.crowdfunderAddress === bb1); + } + if (opts.open) { + // Deadline-only filter — we don't read per-collection balances at + // list scope. 'active' = deadline-in-future; everything past + // deadline is either funded or expired-refunding (need raised). + const now = BigInt(Date.now()); + collections = collections.filter((c: any) => { + const d = extractCrowdfundDetails(c.collectionApprovals); + return d && d.deadlineTime > now; + }); + } + const summary = collections.map((c: any) => { + const d = extractCrowdfundDetails(c.collectionApprovals)!; + return { + collectionId: String(c.collectionId ?? c._docId ?? ''), + crowdfunderAddress: d.crowdfunderAddress, + depositDenom: d.depositDenom, + goalAmount: d.goalAmount.toString(), + deadlineTime: d.deadlineTime.toString(), + // Deadline-only fallback status — `crowdfund show ` returns the + // full status with raised balance. + status: d.deadlineTime > BigInt(Date.now()) ? 'active' : 'expired-or-funded' + }; + }); + emit(summary, opts); + } catch (err) { emitError(err); } +}); + +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('show') + .description('Render a Crowdfund collection — goal / raised / deadline / status.') + .argument('', 'Crowdfund collection ID') + ) +).action(async (collectionId: string, opts: NetworkFlags & OutputFlags) => { + try { + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'crowdfund show'); + const details = extractCrowdfundDetails(collection.collectionApprovals)!; + const raised = await readRaised(collection, opts, details); + const status = deriveCrowdfundStatus(details.deadlineTime, raised, details.goalAmount); + emit({ + collectionId: String(collectionId), + crowdfunderAddress: details.crowdfunderAddress, + depositDenom: details.depositDenom, + goalAmount: details.goalAmount.toString(), + raised: raised.toString(), + deadlineTime: details.deadlineTime.toString(), + mintEscrowAddress: collection.mintEscrowAddress ?? null, + status + }, opts); + } catch (err) { emitError(err); } +}); + +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('status') + .description('Resolve current status: active / funded / goal-met-pending-settle / expired-refunding.') + .argument('', 'Crowdfund collection ID') + ) +).action(async (collectionId: string, opts: NetworkFlags & OutputFlags) => { + try { + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'crowdfund status'); + const details = extractCrowdfundDetails(collection.collectionApprovals)!; + const raised = await readRaised(collection, opts, details); + emit({ + collectionId: String(collectionId), + raised: raised.toString(), + goal: details.goalAmount.toString(), + status: deriveCrowdfundStatus(details.deadlineTime, raised, details.goalAmount) + }, opts); + } catch (err) { emitError(err); } +}); + +addDeployOptions( +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('contribute') + .description('Emit a 2-msg tx that contributes to the crowdfund. Pipe to `bb deploy`.') + .argument('', 'Crowdfund collection ID') + .requiredOption('--creator
', 'Contributor address (bb1...) — strict; run `bb account convert` for 0x') + .requiredOption('--amount ', 'Amount to contribute. Display units when the crowdfund\'s deposit denom is a registered symbol; base units when it\'s a chain denom. Use --base-units to force base-units.') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) +)).action(async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string; amount: string; baseUnits?: boolean }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'crowdfund contribute'); + const details = extractCrowdfundDetails(collection.collectionApprovals)!; + const { amount: amountStr } = resolveAmount( + opts.amount, + details.depositDenom, + Boolean(opts.baseUnits), + { amountFlag: '--amount', denomFlag: 'crowdfund deposit denom' } + ); + const tx = buildContributeCrowdfundTx(creator, String(collectionId), details, BigInt(amountStr)); + // Single-msg per the helper output. + await runEmitOrDeploy(tx.messages[0], opts, { emit: (m) => emit(m, opts), expectedAddress: creator }); + } catch (err) { emitError(err); } +}).addHelpText('after', ` +Examples: + $ bb crowdfunds contribute 7 --creator bb1backer...xyz --amount 100 | bb deploy + $ bb crowdfunds contribute 7 --creator bb1backer...xyz --amount 100000000 --base-units | bb deploy +`); + +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('withdraw') + .description('Crowdfunder-only: emit the 2-msg withdraw tx (drain escrow + burn progress tokens) when goal is met. Pipe to `bb deploy`.') + .argument('', 'Crowdfund collection ID') + .requiredOption('--creator
', 'Crowdfunder address (bb1.../0x — auto-normalized)') + ) +).action(async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'crowdfund withdraw'); + const details = extractCrowdfundDetails(collection.collectionApprovals)!; + if (creator !== details.crowdfunderAddress) { + process.stderr.write(`Warning: --creator ${creator} does not match crowdfunder ${details.crowdfunderAddress}. The on-chain approval will reject this tx.\n`); + } + const raised = await readRaised(collection, opts, details); + if (raised < details.goalAmount) { + process.stderr.write(`Warning: raised (${raised}) < goal (${details.goalAmount}). Withdraw will be rejected — wait for goal to be met.\n`); + } + // Find the optional burn approval used for token-2 burn (FE: `fromListId === '!Mint' && toListId === burn && no coinTransfers`) + const burnApprovalId = (collection.collectionApprovals ?? []).find( + (a: any) => + a.fromListId === '!Mint' && + a.toListId === BURN_ADDRESS && + (a.approvalCriteria?.coinTransfers?.length ?? 0) === 0 + )?.approvalId; + const tx = buildWithdrawCrowdfundTx(creator, String(collectionId), details, raised, burnApprovalId); + emit(tx, opts); + } catch (err) { emitError(err); } +}).addHelpText('after', ` +Examples: + $ bb crowdfunds withdraw 7 --creator bb1crowdfunder...xyz | bb deploy +`); + +addDeployOptions( +addOutputFlags( + addNetworkFlags( + crowdfundsCommand + .command('refund') + .description('Contributor refund (after deadline if goal not met): emit single MsgTransferTokens. Pipe to `bb deploy`.') + .argument('', 'Crowdfund collection ID') + .requiredOption('--creator
', 'Contributor address (bb1...) — strict; run `bb account convert` for 0x') + .requiredOption('--amount ', 'Amount to refund. Display units when the crowdfund\'s deposit denom is a registered symbol; base units when it\'s a chain denom. Use --base-units to force base-units.') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) +)).action(async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string; amount: string; baseUnits?: boolean }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'crowdfund refund'); + const details = extractCrowdfundDetails(collection.collectionApprovals)!; + const { amount: amountStr } = resolveAmount( + opts.amount, + details.depositDenom, + Boolean(opts.baseUnits), + { amountFlag: '--amount', denomFlag: 'crowdfund deposit denom' } + ); + const now = BigInt(Date.now()); + if (now <= details.deadlineTime) { + process.stderr.write(`Warning: deadline not yet passed (deadline=${details.deadlineTime}, now=${now}). Refund will be rejected.\n`); + } + await runEmitOrDeploy(buildRefundCrowdfundMsg(creator, String(collectionId), details, BigInt(amountStr)), opts, { emit: (m) => emit(m, opts), expectedAddress: creator }); + } catch (err) { emitError(err); } +}).addHelpText('after', ` +Examples: + $ bb crowdfunds refund 7 --creator bb1backer...xyz --amount 100 | bb deploy +`); + +// Per-standard `build` subcommand removed in CLI v2 (#0399). +// Use `bb build crowdfund ...` (the canonical builder) instead. diff --git a/packages/bitbadgesjs-sdk/src/cli/index.ts b/packages/bitbadgesjs-sdk/src/cli/index.ts index e40daa4a8e..d08cbcacee 100644 --- a/packages/bitbadgesjs-sdk/src/cli/index.ts +++ b/packages/bitbadgesjs-sdk/src/cli/index.ts @@ -190,6 +190,7 @@ import { subscriptionsCommand } from './commands/subscriptions.js'; import { intentsCommand } from './commands/intents.js'; import { creditTokensCommand } from './commands/credit-tokens.js'; import { productsCommand } from './commands/products.js'; +import { crowdfundsCommand } from './commands/crowdfunds.js'; import { auctionsCommand } from './commands/auctions.js'; import { predictionMarketsCommand } from './commands/prediction-markets.js'; import { smartTokensCommand } from './commands/smart-tokens.js'; @@ -297,6 +298,7 @@ const HELP_GROUPS: { title: string; commands: Command[] }[] = [ intentsCommand, creditTokensCommand, productsCommand, + crowdfundsCommand, auctionsCommand, predictionMarketsCommand, smartTokensCommand, @@ -321,6 +323,7 @@ import { makeBuildAlias } from './utils/build-alias.js'; const STANDARD_BUILD_ALIASES: Record = { auctions: 'auction', bounties: 'bounty', + crowdfunds: 'crowdfund', 'credit-tokens': 'credit-token', intents: 'intent', 'pay-requests': 'payment-request', diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/cli-core.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/cli-core.spec.ts index bf800d6106..239fea19e9 100644 --- a/packages/bitbadgesjs-sdk/src/cli/integration/cli-core.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/integration/cli-core.spec.ts @@ -222,7 +222,7 @@ describe('cli-core integration', () => { const out = runCli(['--help-json'], { parseJson: false }); const stdout = out.stdout; expect(stdout).toContain('"commands"'); - for (const n of ['pay-requests', 'bounties', 'subscriptions', 'intents', 'credit-tokens', 'products', 'auctions', 'prediction-markets', 'nfts']) { + for (const n of ['pay-requests', 'bounties', 'subscriptions', 'intents', 'credit-tokens', 'products', 'crowdfunds', 'auctions', 'prediction-markets', 'nfts']) { expect(stdout).toContain(`"${n}"`); } }); diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/cli-misc.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/cli-misc.spec.ts index 887496a29a..e0b3f98eab 100644 --- a/packages/bitbadgesjs-sdk/src/cli/integration/cli-misc.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/integration/cli-misc.spec.ts @@ -34,7 +34,7 @@ describe('bb completion', () => { // v2 of the generator walks the full tree, so `auctions create` (or // any other 2-level subcommand) should appear as a quoted case key. const out = runCli(['completion'], { parseJson: false }); - expect(out.stdout).toMatch(/'(auctions|bounties|subscriptions|smart-tokens|nfts) [a-z-]+'\)/); + expect(out.stdout).toMatch(/'(auctions|bounties|subscriptions|crowdfunds|smart-tokens|nfts) [a-z-]+'\)/); }); }); diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/crowdfund-terminal.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/crowdfund-terminal.spec.ts new file mode 100644 index 0000000000..57c75fe0e3 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/integration/crowdfund-terminal.spec.ts @@ -0,0 +1,138 @@ +/** + * Integration: Crowdfund TERMINAL branches (cluster-2) — never run before. + * Self-contained (own short-deadline collections); does not touch the + * existing crowdfunds.spec.ts. Sleep-optimized: BOTH time-gated + * crowdfunds deployed up front, ONE consolidated wait past the deadline. + * + * A = funded (raised ≥ goal) ; B = under-goal. After the single wait: + * - withdraw(A) happy → crowdfunder USDC rises ≈ raised (economic) + * - refund(B) happy → contributor USDC refunded ≈ contributed + * - status transitions: A funded / B expired-refunding + * - contribute-after-deadline REJECTED + * - withdraw-when-goal-not-met REJECTED + * - refund-when-goal-IS-met REJECTED (escrow-drain security guard) + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { preflightIntegration } from './harness/preflight.js'; +import { alice, bob, dave } from './harness/personas.js'; +import { runCli } from './harness/cli.js'; +import { + deployMsgViaKeyring, fundMany, waitForIndexerCollection, writeMsgToTmp, sleep, getBankBalance +} from './harness/chain.js'; + +const USDC = 'ibc/F082B65C88E4B6D5EF1DB243CDA1D331D002759E938A0F5CD3FFDC5D53B3E349'; + +function buildCrowdfund(name: string, deadline: string) { + const tmp = path.join(os.tmpdir(), `cf-${crypto.randomBytes(4).toString('hex')}.json`); + runCli(['build', 'crowdfund', '--goal', '10', '--denom', 'USDC', + '--crowdfunder', alice().address, '--deadline', deadline, + '--name', name, '--image', 'https://example.com/cf.png', '--description', name, + '--creator', alice().address, '--output-file', tmp], { parseJson: false }); + return tmp; +} +function contribute(id: string, who: { address: string; name: string }, baseAmt: string) { + const m = runCli(['crowdfunds', 'contribute', id, '--creator', who.address, + '--amount', baseAmt, '--base-units', '--local']); + return deployMsgViaKeyring(writeMsgToTmp(m.json, 'cf-contrib'), who.name); +} +// FINDING 0438 (root-caused — CONFIRMED real fund-safety bug, NOT +// calibration): `contribute --amount N` mints N token-2 (=> `raised`) +// but the deposit-refund coinTransfer escrows N − ~0.1% (consistent +// with a chain coin-transfer tax; at tiny N the 0.1% rounds to 0, +// which is why micro-amounts looked 1:1). `success`/`refund` then pay +// out the GROSS `raised` from the escrow, which is perpetually short → +// **a fully-funded crowdfund cannot be withdrawn or refunded**. +// Reproduced deterministically below (escrow < raised; withdraw does +// not succeed). The fix is design/chain-ambiguous (gate on escrow +// balance vs token-2 / pay escrow-available / exempt escrow transfers +// from the tax) so it is NOT auto-fixed here — 0438 stays open for a +// decision. Status / contribute-after-deadline / refund-when-met +// security-guard branches remain green. + +describe('crowdfund terminal-state integration', () => { + let ready = false; + let A: string | undefined; // funded + let B: string | undefined; // under-goal + let tBuild = 0; + + beforeAll(async () => { + ready = (await preflightIntegration()).ok; + if (!ready) return; + await fundMany('alice', [ + { toAddress: bob().address, amount: '6000000', denom: 'ubadge' }, + { toAddress: dave().address, amount: '6000000', denom: 'ubadge' }, + { toAddress: bob().address, amount: '15000000', denom: USDC }, + { toAddress: dave().address, amount: '8000000', denom: USDC } + ]); + tBuild = Date.now(); + const aTx = await deployMsgViaKeyring(buildCrowdfund('CF-Funded', '12s'), alice().name); + const bTx = await deployMsgViaKeyring(buildCrowdfund('CF-Under', '12s'), alice().name); + expect(aTx.code).toBe(0); expect(bTx.code).toBe(0); + A = aTx.collectionId!; B = bTx.collectionId!; + await Promise.all([waitForIndexerCollection(A), waitForIndexerCollection(B)]); + // Fund A to goal (bob contributes 10 USDC); B partial (dave 4 USDC). + expect((await contribute(A, bob(), '10000000')).code).toBe(0); + expect((await contribute(B, dave(), '4000000')).code).toBe(0); + // ONE consolidated wait past the (build-time) 12s deadline + a block. + const remain = tBuild + 12000 + 2500 - Date.now(); + if (remain > 0) await sleep(remain); + }, 180000); + + it('status: A funded (goal met, past deadline) / B expired-refunding', async () => { + if (!ready || !A || !B) return; + let sa = ''; + for (let i = 0; i < 12; i++) { + sa = runCli(['crowdfunds', 'status', A, '--local']).json.status; + if (sa === 'funded') break; + await sleep(2000); + } + expect(['funded', 'goal-met-pending-settle']).toContain(sa); + const sb = runCli(['crowdfunds', 'status', B, '--local']).json.status; + expect(['expired-refunding', 'active']).toContain(sb); + }, 60000); + + it('FINDING 0438: escrow ends up SHORT of raised → a fully-funded crowdfund cannot be withdrawn', async () => { + if (!ready || !A) return; + // A was built --goal 10 and bob contributed 10_000_000 base in + // beforeAll. token-2 raised == 10_000_000 but the escrow received + // ~0.1% less (the bug). Pin both halves so this flips green→fail + // when 0438 is fixed. + const show = runCli(['crowdfunds', 'show', A, '--local']).json; + const escrowAddr = show.mintEscrowAddress; + expect(typeof escrowAddr).toBe('string'); + const escrow = getBankBalance(escrowAddr, USDC); + expect(escrow).toBeGreaterThan(0n); // contribute DID fund the escrow … + expect(escrow).toBeLessThan(10_000_000n); // … but SHORT of raised (the 0438 bug) + // …so withdrawing a fully-met goal does NOT succeed (insufficient escrow). + let withdrew = false; + const w = runCli(['crowdfunds', 'withdraw', A, '--creator', alice().address, '--local'], + { throwOnError: false }); + if (w.exitCode === 0 && w.json && (w.json.messages || w.json.typeUrl)) { + try { withdrew = (await deployMsgViaKeyring(writeMsgToTmp(w.json, 'cf-wd-0438'), alice().name)).code === 0; } + catch { withdrew = false; } + } + expect(withdrew).toBe(false); + }, 90000); + + it('contribute-after-deadline is REJECTED (B)', async () => { + if (!ready || !B) return; + let code: number | undefined; + try { code = (await contribute(B, dave(), '1000000')).code; } catch { code = 1; } + expect(code).not.toBe(0); + }, 60000); + + it('refund-when-goal-IS-met is REJECTED (escrow-drain security guard, A)', async () => { + if (!ready || !A) return; + let code: number | undefined; + try { + const r = runCli(['crowdfunds', 'refund', A, '--creator', bob().address, + '--amount', '10000000', '--base-units', '--local']); + code = (await deployMsgViaKeyring(writeMsgToTmp(r.json, 'cf-refund-bad'), bob().name)).code; + } catch { code = 1; } + expect(code).not.toBe(0); + }, 60000); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/crowdfunds.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/crowdfunds.spec.ts new file mode 100644 index 0000000000..74473907cd --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/integration/crowdfunds.spec.ts @@ -0,0 +1,215 @@ +/** + * Integration: `bb crowdfunds` end-to-end. (Legacy `bb crowdfund` alias also covered.) + * + * Personas: + * - alice → crowdfunder (creates the campaign, has genesis USDC) + * - charlie → contributor (funded inline before contributing) + * + * Flow exercised: + * 1. alice builds + deploys a Crowdfund collection (goal 1000 USDC) + * 2. indexer indexes it + * 3. `bb crowdfunds show` returns goal=1e9 (1000 × 1e6 base units), status='active' + * 4. `bb crowdfunds status` mirrors the same view + * 5. charlie pipes `crowdfunds contribute` → `deploy --with-keyring` → chain code 0 + * (single MsgTransferTokens with 2 transfers inside: mint-1 to charlie + mint-2 to alice) + * 6. raised flips upward (or accept indexer lag with a stderr log) + * 7. Negative: charlie (not the crowdfunder) running `withdraw` surfaces the + * "doesn't match crowdfunder" warning on stderr. + * + * Skipped automatically when preflight fails. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { preflightIntegration } from './harness/preflight.js'; +import { alice, charlie } from './harness/personas.js'; +import { runCli } from './harness/cli.js'; +import { deployMsgViaKeyring, fundPersona, waitForIndexerCollection, writeMsgToTmp } from './harness/chain.js'; + +// USDC on local chain. alice has a genesis allocation; charlie is funded +// inline via `fundPersona` before contributing. +const USDC_DENOM = 'ibc/F082B65C88E4B6D5EF1DB243CDA1D331D002759E938A0F5CD3FFDC5D53B3E349'; +// 1000 USDC at 6 decimals = 1e9 base units. +const GOAL_DISPLAY = 1000; +const GOAL_BASE = '1000000000'; +// 100 USDC at 6 decimals = 1e8 base units. +const CONTRIBUTE_BASE = '100000000'; + +// Wrap fundPersona with a single retry on sequence-mismatch. Alice fans +// out multiple txs in close succession (build/deploy → bank-send) and the +// chain-binary's local sequence view sometimes lags the node's by one +// block. A short pause + one retry clears the race. +async function fundWithRetry( + fromName: string, + toAddress: string, + amount: string, + denom: string +): Promise { + try { + await fundPersona(fromName, toAddress, amount, denom); + } catch (err) { + const msg = (err as Error).message || ''; + if (/sequence mismatch/i.test(msg)) { + await new Promise((r) => setTimeout(r, 2500)); + await fundPersona(fromName, toAddress, amount, denom); + } else { + throw err; + } + } +} + +describe('crowdfunds integration', () => { + let ready = false; + let collectionId: string | undefined; + + beforeAll(async () => { + ready = (await preflightIntegration()).ok; + }, 30000); + + it('build + deploy creates a Crowdfund collection', async () => { + if (!ready) return; + const crowdfunder = alice(); + const tmp = path.join(os.tmpdir(), `cf-build-${crypto.randomBytes(4).toString('hex')}.json`); + + runCli( + [ + 'build', + 'crowdfund', + '--goal', String(GOAL_DISPLAY), + '--denom', 'USDC', + '--crowdfunder', crowdfunder.address, + '--deadline', '1d', + '--name', 'Test CF', + '--image', 'https://example.com/cf.png', + '--description', 'test crowdfund desc long enough', + '--output-file', tmp + ], + { parseJson: false } // build emits a review block, not pure JSON + ); + expect(fs.existsSync(tmp)).toBe(true); + + const tx = await deployMsgViaKeyring(tmp, crowdfunder.name); + expect(tx.code).toBe(0); + expect(tx.collectionId).toBeDefined(); + collectionId = tx.collectionId!; + await waitForIndexerCollection(collectionId); + }, 90000); + + it('show returns goal=1e9, raised=0, status=active', async () => { + if (!ready || !collectionId) return; + const crowdfunder = alice(); + const show = runCli(['crowdfunds', 'show', collectionId, '--local']); + expect(show.json.collectionId).toBe(collectionId); + expect(show.json.crowdfunderAddress).toBe(crowdfunder.address); + expect(show.json.depositDenom).toBe(USDC_DENOM); + expect(show.json.goalAmount).toBe(GOAL_BASE); + expect(show.json.raised).toBe('0'); + expect(show.json.status).toBe('active'); + }, 30000); + + it('status mirrors show (active, raised=0, goal=1e9)', async () => { + if (!ready || !collectionId) return; + const status = runCli(['crowdfunds', 'status', collectionId, '--local']); + expect(status.json.collectionId).toBe(collectionId); + expect(status.json.goal).toBe(GOAL_BASE); + expect(status.json.raised).toBe('0'); + expect(status.json.status).toBe('active'); + }, 30000); + + it('charlie contributes 100 USDC → chain code 0', async () => { + if (!ready || !collectionId) return; + const contributor = charlie(); + + // Fund charlie with enough USDC to cover the 100-USDC contribution + // (display 100 → 100,000,000 base units). Over-fund a bit for fees. + // Retry once on sequence-mismatch — alice's prior build+deploy tx may + // still be propagating when this bank-send fires. + await fundWithRetry('alice', contributor.address, '200000000', USDC_DENOM); + + const contributeMsg = runCli([ + 'crowdfunds', 'contribute', collectionId, + '--creator', contributor.address, + '--amount', CONTRIBUTE_BASE, + '--local' + ]); + // crowdfunds contribute emits a SINGLE MsgTransferTokens envelope + // (with 2 transfers inside — mint-1 to charlie, mint-2 to alice). + expect(contributeMsg.json.typeUrl).toBe('/tokenization.MsgTransferTokens'); + expect(Array.isArray(contributeMsg.json.value.transfers)).toBe(true); + expect(contributeMsg.json.value.transfers.length).toBe(2); + + const tmp = writeMsgToTmp(contributeMsg.json, 'cf-contribute'); + const tx = await deployMsgViaKeyring(tmp, contributor.name); + expect(tx.code).toBe(0); + }, 90000); + + it('after contribute, raised increases (or indexer lag accepted)', async () => { + if (!ready || !collectionId) return; + // Poll status until raised flips, OR timeout. Indexer-side balance + // index may lag the chain by a few seconds. + const start = Date.now(); + let raised = '0'; + while (Date.now() - start < 45000) { + raised = runCli(['crowdfunds', 'status', collectionId, '--local']).json.raised; + if (raised !== '0') break; + await new Promise((r) => setTimeout(r, 2000)); + } + if (raised === '0') { + process.stderr.write( + `[integration] crowdfunds raised still 0 after 45s — indexer may be lagging. Tx was code 0 on chain.\n` + ); + } + // Either the indexer caught up (raised >= 100,000,000) or it didn't + // (raised still 0). Both are acceptable — the chain tx is authoritative. + expect(['0', CONTRIBUTE_BASE]).toContain(raised); + }, 60000); + + it('withdraw by non-crowdfunder warns "doesn\'t match crowdfunder" on stderr', async () => { + if (!ready || !collectionId) return; + const contributor = charlie(); + // charlie is NOT the crowdfunder — the CLI should print the + // mismatch warning to stderr (and also a raised < goal warning, + // since goal isn't met). We just verify the mismatch surfaces. + const out = runCli( + ['crowdfunds', 'withdraw', collectionId, '--creator', contributor.address, '--local'], + { throwOnError: false, parseJson: false } + ); + expect(out.stderr).toMatch(/does not match crowdfunder|doesn't match crowdfunder/i); + }, 30000); + + it('conformance throw — show on a non-Crowdfund collection exits non-zero', async () => { + if (!ready) return; + // Collection 1 (BADGE) is not a Crowdfund — validator must reject. + const out = runCli(['crowdfunds', 'show', '1', '--local'], { throwOnError: false, parseJson: false }); + expect(out.exitCode).not.toBe(0); + expect(out.stderr + out.stdout).toMatch(/not.*found|not.*valid|Crowdfund/i); + }, 30000); + + it('list surfaces our crowdfund (regression: bigintify-before-validate)', async () => { + if (!ready || !collectionId) return; + // Regression guard: before the fix, `bb crowdfunds list` filtered out + // every browse row because `validateCrowdfundCollection` compares + // tokenIds against 1n/2n bigints — string ids from `/browse` silently + // failed validation. With the fix, our just-deployed crowdfund must + // appear in the global list. + const list = runCli(['crowdfunds', 'list', '--local']); + expect(Array.isArray(list.json)).toBe(true); + const ours = list.json.find((row: any) => row.collectionId === collectionId); + expect(ours).toBeDefined(); + expect(ours.crowdfunderAddress).toBe(alice().address); + expect(ours.depositDenom).toMatch(/^ibc\//); + expect(['active', 'expired-or-funded']).toContain(ours.status); + }, 30000); + + it('list --mine scopes correctly', async () => { + if (!ready || !collectionId) return; + const list = runCli(['crowdfunds', 'list', '--mine', alice().address, '--local']); + expect(Array.isArray(list.json)).toBe(true); + // Every row should belong to alice. + for (const row of list.json) { + expect(row.crowdfunderAddress).toBe(alice().address); + } + }, 30000); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts index 7a495dad8a..b44a089b36 100644 --- a/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/utils/collection-options.spec.ts @@ -62,9 +62,9 @@ describe('validateCollectionOrExit', () => { it('prints errors + warnings and exits 2 when invalid', () => { const bad = () => ({ valid: false, errors: ['e1', 'e2'], warnings: ['w1'] }); - expect(() => validateCollectionOrExit({}, 'mint', bad, 'Auction')).toThrow('process.exit'); + expect(() => validateCollectionOrExit({}, 'mint', bad, 'Crowdfund')).toThrow('process.exit'); const txt = stderrSpy.mock.calls.map((c) => c[0]).join(''); - expect(txt).toContain('not a valid Auction (failed in mint)'); + expect(txt).toContain('not a valid Crowdfund (failed in mint)'); expect(txt).toContain('- e1'); expect(txt).toContain('- e2'); expect(txt).toContain('- w1'); diff --git a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts index bc894b7c0c..d45921d36d 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/builders.spec.ts @@ -10,6 +10,7 @@ import { buildVault } from './vault.js'; import { buildSubscription } from './subscription.js'; import { buildBounty } from './bounty.js'; import { buildPaymentRequest } from './payment-request.js'; +import { buildCrowdfund } from './crowdfund.js'; import { buildAuction } from './auction.js'; import { buildProductCatalog } from './product-catalog.js'; import { buildPredictionMarket } from './prediction-market.js'; @@ -461,6 +462,50 @@ describe('payment-request builder', () => { }); }); +describe('crowdfund builder', () => { + const params = { goal: 1000, denom: 'USDC', crowdfunder: 'bb1fund', ...META }; + const msg = buildCrowdfund(params); + const r = val(msg); + + test('has Crowdfund standard', () => { expect(r.standards).toEqual(['Crowdfund']); }); + test('2 token IDs', () => { expect(r.validTokenIds).toEqual([{ start: '1', end: '2' }]); }); + test('at least 4 approvals', () => { expect(r.collectionApprovals.length).toBeGreaterThanOrEqual(4); }); + test('crowdfunder address used', () => { + const progress = r.collectionApprovals.find((a: any) => a.approvalId === 'deposit-progress'); + expect(progress.toListId).toBe('bb1fund'); + }); + + test('throws without a payout address (no crowdfunder/creator)', () => { + expect(() => buildCrowdfund({ goal: 1000, denom: 'USDC', ...META })).toThrow( + /requires a payout address/ + ); + }); + test('falls back to creator when crowdfunder omitted', () => { + const viaCreator = val(buildCrowdfund({ goal: 1000, denom: 'USDC', creator: 'bb1creator', ...META })); + const progress = viaCreator.collectionApprovals.find((a: any) => a.approvalId === 'deposit-progress'); + expect(progress.toListId).toBe('bb1creator'); + }); + test('success + refund gate on the crowdfunder via ownershipCheckParty', () => { + for (const idName of ['success', 'refund']) { + const a = r.collectionApprovals.find((x: any) => x.approvalId === idName); + expect(a.approvalCriteria.mustOwnTokens[0].ownershipCheckParty).toBe('bb1fund'); + } + }); + test('deterministic — stable ids + payout gate across calls', () => { + // deadlineTs is Date.now()-relative (pre-existing), so compare the + // parts the builder controls deterministically. + const a = val(buildCrowdfund(params)); + const b = val(buildCrowdfund(params)); + expect(a.collectionApprovals.map((x: any) => x.approvalId)) + .toEqual(b.collectionApprovals.map((x: any) => x.approvalId)); + expect(a.collectionApprovals.find((x: any) => x.approvalId === 'refund').approvalCriteria.mustOwnTokens[0].ownershipCheckParty) + .toBe(b.collectionApprovals.find((x: any) => x.approvalId === 'refund').approvalCriteria.mustOwnTokens[0].ownershipCheckParty); + }); + test('passes verification with zero violations', () => { + expectCleanVerification(msg); + }); +}); + describe('auction builder', () => { const msg = buildAuction({ ...META }); const r = val(msg); @@ -1031,6 +1076,8 @@ describe('all collection builders pass verifyStandardsCompliance with zero viola ['subscription (multi-payout)', buildSubscription({ interval: 'monthly', payouts: [{ recipient: 'bb1a', amount: 5, denom: 'USDC' }, { recipient: 'bb1b', amount: 3, denom: 'USDC' }], ...META })], ['bounty', buildBounty({ amount: 100, denom: 'USDC', verifier: 'bb1v', recipient: 'bb1r', submitter: 'bb1s', ...META })], ['bounty (BADGE)', buildBounty({ amount: 50, denom: 'BADGE', verifier: 'bb1v', recipient: 'bb1r', submitter: 'bb1s', expiration: '7d', ...META })], + ['crowdfund', buildCrowdfund({ goal: 1000, denom: 'USDC', crowdfunder: 'bb1fund', ...META })], + ['crowdfund (with crowdfunder)', buildCrowdfund({ goal: 500, denom: 'BADGE', crowdfunder: 'bb1fund', deadline: '14d', ...META })], ['auction', buildAuction({ ...META })], ['auction (custom times)', buildAuction({ bidDeadline: '3d', acceptWindow: '1d', ...META })], ['product-catalog', buildProductCatalog({ products: [{ name: 'Item', price: 10, denom: 'USDC' }], storeAddress: 'bb1s', ...META })], @@ -1084,6 +1131,7 @@ describe('error handling', () => { 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); }); @@ -1099,6 +1147,17 @@ describe('error handling', () => { expectCleanVerification(msg); }); + test('buildCrowdfund goal of 1 base unit: goal converts to 1 base unit + verifier-clean', () => { + const msg = buildCrowdfund({ goal: 0.000001, denom: 'USDC', crowdfunder: 'bb1fund', ...META }); + expect(msg.value.collectionApprovals.length).toBeGreaterThanOrEqual(4); + // USDC has 6 decimals → 0.000001 must resolve to exactly "1" base + // unit somewhere in the emitted coin amounts (not 0 from a silent + // truncation, not display-units). + const coinAmounts = JSON.stringify(msg).match(/"amount":"\d+"/g) ?? []; + expect(coinAmounts).toContain('"amount":"1"'); + expectCleanVerification(msg); + }); + test('buildAuction with very short windows: well-formed + verifier-clean', () => { const msg = buildAuction({ bidDeadline: '1m', acceptWindow: '1m', ...META }); expect(msg.typeUrl).toBe('/tokenization.MsgUniversalUpdateCollection'); diff --git a/packages/bitbadgesjs-sdk/src/core/builders/crowdfund.ts b/packages/bitbadgesjs-sdk/src/core/builders/crowdfund.ts new file mode 100644 index 0000000000..84d573ebeb --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/builders/crowdfund.ts @@ -0,0 +1,328 @@ +/** + * Crowdfund builder — creates a MsgUniversalUpdateCollection for a crowdfund campaign. + * @module core/builders/crowdfund + */ +import { + MAX_UINT64, + FOREVER, + BURN_ADDRESS, + resolveCoin, + toBaseUnits, + durationToTimestamp, + buildMsg, + frozenPermissions, + defaultBalances, + scalingBalances, + tokenMetadataEntry, + metadataFromFlat, + MetadataMissingError, + approvalMetadata +} from './shared.js'; + +export interface CrowdfundParams { + goal: number; // display units + denom: string; + crowdfunder?: string; // bb1... address — who receives funds on success (creator fills in if empty) + deadline?: string; // duration shorthand, default "30d" + /** Pre-hosted collection metadata URI. If provided, name/image/description are ignored. */ + uri?: string; + name?: string; + description?: string; + image?: string; + /** + * Creator address — used as the default crowdfunder when `crowdfunder` + * isn't specified. The CLI passes this through from `--creator`. + * Without a real address the resulting tx is broken (the success/refund + * approvals would have `toListId: 'All'` which is meaningless for an + * escrow payout). + */ + creator?: string; +} + +export function buildCrowdfund(params: CrowdfundParams): any { + const coin = resolveCoin(params.denom); + const goalBase = toBaseUnits(params.goal, coin.decimals); + const deadlineTs = durationToTimestamp(params.deadline || '30d'); + // The crowdfunder is the escrow payout target AND the party whose + // Token-2 progress balance gates success/refund (ownershipCheckParty + // below). It must be a concrete address — falling back to 'All' would + // build a collection the chain accepts but that pays out to no one and + // whose goal gate checks the wrong party. The frontend + // CrowdfundRegistry takes `crowdfunderAddress` as a required param; + // mirror that here. Prefer the explicit flag, then CLI --creator. + const crowdfunderAddr = params.crowdfunder || params.creator; + if (!crowdfunderAddr) { + throw new Error( + 'Crowdfund requires a payout address. Pass --crowdfunder (it otherwise inherits --creator).' + ); + } + + const collectionApprovals = [ + // Deposit-Refund — public deposit, mints refund receipt (token 1) + { + fromListId: 'Mint', + toListId: 'All', + initiatedByListId: 'All', + approvalId: 'deposit-refund', + ...approvalMetadata( + 'Deposit', + 'Contribute USDC and receive a refund token' + ), + transferTimes: [{ start: '1', end: deadlineTs }], + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { + requireToEqualsInitiatedBy: true, + // `allowAmountScaling` lives inside predeterminedBalances.incrementedBalances + // (set by `scalingBalances`). Was duplicated here too — chain proto + // rejected with "unknown field allowAmountScaling in ApprovalCriteria". + predeterminedBalances: scalingBalances('1'), + coinTransfers: [ + { + to: 'Mint', + coins: [{ amount: '1', denom: coin.denom }], + overrideFromWithApproverAddress: false, + overrideToWithInitiator: false + } + ], + // Mint approval: outgoing override required by standard. + // Incoming: recipient auto-approves via defaultBalances. + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: false + } + }, + // Deposit-Progress — tracks total deposits via token 2 to the + // crowdfunder (a concrete address; the 'All' fallback was removed). + { + fromListId: 'Mint', + toListId: crowdfunderAddr, + initiatedByListId: 'All', + approvalId: 'deposit-progress', + ...approvalMetadata( + 'Progress Tracker', + 'Tracks cumulative contributions to crowdfunder' + ), + transferTimes: [{ start: '1', end: deadlineTs }], + tokenIds: [{ start: '2', end: '2' }], + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { + predeterminedBalances: (() => { + // scalingBalances hardcodes startBalance tokenIds to [{1,1}]; + // override for token-2 (the progress tracker). Without this, + // chain rejects: "amount scaling: transfer is not an evenly + // divisible multiple of the base balance" because the + // approval would generate tokenIds=[{1,1}] when we need token-2. + const base = scalingBalances('1'); + return { + ...base, + incrementedBalances: { + ...base.incrementedBalances, + startBalances: [ + { amount: '1', tokenIds: [{ start: '2', end: '2' }], ownershipTimes: FOREVER } + ] + } + }; + })(), + // Mint approval: outgoing override required by standard. + // Incoming: crowdfunder auto-approves via defaultBalances. + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: false + } + }, + // Success — crowdfunder withdraws funds after deadline if goal met. + // toListId is BURN_ADDRESS: the success approval burns the deposit + // receipt token and the coinTransfer (with + // overrideFromWithApproverAddress) routes the underlying funds to + // the crowdfunder via the escrow. Routing tokens to + // crowdfunderAddr directly would mint an NFT to them, which isn't + // the intended semantic. + { + fromListId: 'Mint', + toListId: BURN_ADDRESS, + initiatedByListId: crowdfunderAddr, + approvalId: 'success', + ...approvalMetadata( + 'Withdraw', + 'Crowdfunder withdraws funds when goal is met' + ), + // Strictly AFTER deadline — `deadlineTs + 1` prevents the + // success claim from racing the final deposit at the exact + // deadline second. Matches CrowdfundRegistry's + // `{start: deadlineTime + 1n, end: MAX_UINT}`. + transferTimes: [{ start: String(BigInt(deadlineTs) + 1n), end: MAX_UINT64 }], + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { + mustOwnTokens: [ + { + collectionId: '0', + amountRange: { start: goalBase, end: MAX_UINT64 }, + ownershipTimes: FOREVER, + tokenIds: [{ start: '2', end: '2' }], + overrideWithCurrentTime: true, + mustSatisfyForAllAssets: true, + // Gate on the crowdfunder's Token-2 progress balance, not the + // initiator's. Matches CrowdfundRegistry.successApproval. + ownershipCheckParty: crowdfunderAddr + } + ], + coinTransfers: [ + { + to: '', + coins: [{ amount: '1', denom: coin.denom }], + overrideFromWithApproverAddress: true, + overrideToWithInitiator: true + } + ], + // Only one successful withdrawal ever — the crowdfunder claims + // the escrowed funds once. Frontend uses overall:1. + maxNumTransfers: { + overallMaxNumTransfers: '1', + perToAddressMaxNumTransfers: '0', + perFromAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'crowdfund-success', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + predeterminedBalances: scalingBalances('1'), + overridesFromOutgoingApprovals: true + } + }, + // Refund — backers burn receipt for refund after deadline if goal not met + { + fromListId: '!Mint', + toListId: BURN_ADDRESS, + initiatedByListId: 'All', + approvalId: 'refund', + ...approvalMetadata( + 'Refund', + 'After deadline, burn refund token to reclaim deposit' + ), + // Same `deadlineTs + 1` boundary as the success approval — no + // refunds at the exact deadline second, only strictly after. + transferTimes: [{ start: String(BigInt(deadlineTs) + 1n), end: MAX_UINT64 }], + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { + mustOwnTokens: [ + { + collectionId: '0', + amountRange: { start: '0', end: String(BigInt(goalBase) - 1n) }, + ownershipTimes: FOREVER, + tokenIds: [{ start: '2', end: '2' }], + overrideWithCurrentTime: true, + mustSatisfyForAllAssets: true, + // Refund is gated on the crowdfunder's Token-2 progress being + // BELOW goal (campaign failed). Without ownershipCheckParty + // the chain checks the backer's own (always-0) Token-2 + // balance, so refunds would pass even on a met goal — backers + // could drain the escrow. Matches CrowdfundRegistry.refundApproval. + ownershipCheckParty: crowdfunderAddr + } + ], + coinTransfers: [ + { + to: '', + coins: [{ amount: '1', denom: coin.denom }], + overrideFromWithApproverAddress: true, + overrideToWithInitiator: true + } + ], + // Refunds unlimited in aggregate — each backer gates themselves + // via their own deposit receipt ownership (mustOwnTokens). + maxNumTransfers: { + overallMaxNumTransfers: MAX_UINT64, + perToAddressMaxNumTransfers: '0', + perFromAddressMaxNumTransfers: '0', + perInitiatedByAddressMaxNumTransfers: '0', + amountTrackerId: 'crowdfund-refund', + resetTimeIntervals: { startTime: '0', intervalLength: '0' } + }, + predeterminedBalances: scalingBalances('1'), + // No overrides: holder self-initiates their own refund burn and + // auto-approves via defaultBalances. Previously both overrides + // were true, which let any third party initiate the burn and + // redirect the refund payout via overrideToWithInitiator — + // a theft vector closed. + overridesFromOutgoingApprovals: false, + overridesToIncomingApprovals: false + } + }, + // Burn — general burn, always allowed + { + fromListId: '!Mint', + toListId: BURN_ADDRESS, + initiatedByListId: 'All', + approvalId: 'burn', + ...approvalMetadata('Burn', 'Burn leftover refund tokens'), + transferTimes: FOREVER, + tokenIds: [{ start: '1', end: '2' }], + ownershipTimes: FOREVER, + version: '0' + } + ]; + + const collectionSource = metadataFromFlat({ + uri: params.uri, + name: params.name, + description: params.description, + image: params.image + }); + if (!collectionSource) { + throw new MetadataMissingError('crowdfund collectionMetadata', ['name', 'image', 'description']); + } + + // Per-token metadata: refund + progress receipts use distinct fixed + // names regardless of the caller's --name (the standard expects + // these specific receipt names). When the caller passed --uri, + // they've taken responsibility for hosting the per-token JSON too + // and we reuse the same uri. In inline mode we serialize the + // standard receipt names + the caller's image. + const refundSource: any = params.uri + ? { uri: params.uri } + : { + inlineMetadata: { + name: 'Refund Token', + description: 'Refundable share of the crowdfund pool.', + image: params.image as string + } + }; + const progressSource: any = params.uri + ? { uri: params.uri } + : { + inlineMetadata: { + name: 'Progress Token', + description: 'Tracks total deposits in the crowdfund pool.', + image: params.image as string + } + }; + + return buildMsg({ + collectionApprovals, + validTokenIds: [{ start: '1', end: '2' }], + standards: ['Crowdfund'], + collectionPermissions: frozenPermissions(), + defaultBalances: defaultBalances(), + invariants: { + noCustomOwnershipTimes: true, + maxSupplyPerId: '0', + // Non-mint approvals (refund, burn) no longer use override flags, + // so forceful post-mint transfers can be permanently locked. + noForcefulPostMintTransfers: true, + disablePoolCreation: true + }, + // Refund + total-deposit tokens are 1-of-1 receipt-style — no + // fractional denom unit needed. The previous version added alias + // paths with `decimals: 0` which the chain rejects. + aliasPathsToAdd: [], + collectionMetadata: collectionSource, + tokenMetadata: [ + tokenMetadataEntry([{ start: '1', end: '1' }], refundSource, 'refund token'), + tokenMetadataEntry([{ start: '2', end: '2' }], progressSource, 'progress token') + ] + }); +} diff --git a/packages/bitbadgesjs-sdk/src/core/builders/index.ts b/packages/bitbadgesjs-sdk/src/core/builders/index.ts index c5aec06cdf..07ef56560d 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/index.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/index.ts @@ -13,6 +13,7 @@ export { buildVault, type VaultParams } from './vault.js'; export { buildSubscription, type SubscriptionParams, type SubscriptionPayout } from './subscription.js'; export { buildBounty, type BountyParams } from './bounty.js'; export { buildPaymentRequest, type PaymentRequestParams } from './payment-request.js'; +export { buildCrowdfund, type CrowdfundParams } from './crowdfund.js'; export { buildAuction, type AuctionParams } from './auction.js'; export { buildProductCatalog, type ProductCatalogParams, type ProductItem } from './product-catalog.js'; export { buildPredictionMarket, type PredictionMarketParams } from './prediction-market.js'; diff --git a/packages/bitbadgesjs-sdk/src/core/cross-standard-rejection.spec.ts b/packages/bitbadgesjs-sdk/src/core/cross-standard-rejection.spec.ts index ae20f09407..7d7b6e9925 100644 --- a/packages/bitbadgesjs-sdk/src/core/cross-standard-rejection.spec.ts +++ b/packages/bitbadgesjs-sdk/src/core/cross-standard-rejection.spec.ts @@ -16,6 +16,7 @@ import { validateBountyCollection } from './bounties.js'; import { validateProductCatalogCollection } from './products.js'; import { validateAuctionCollection } from './auctions.js'; +import { validateCrowdfundCollection } from './crowdfunds.js'; import { validatePaymentRequestCollection } from './payment-requests.js'; import { validatePredictionMarketCollection, isPredictionMarketValid } from './prediction-markets.js'; import { doesCollectionFollowSubscriptionProtocol } from './subscriptions.js'; @@ -38,6 +39,7 @@ const ALL_STANDARDS = [ 'Bounty', 'Products', 'Auction', + 'Crowdfund', 'PaymentRequest', 'Prediction Market', 'Subscriptions', @@ -80,6 +82,16 @@ describe('cross-standard rejection', () => { } }); + describe('validateCrowdfundCollection', () => { + for (const wrong of otherStandards('Crowdfund')) { + it(`rejects a "${wrong}" collection`, () => { + const r = validateCrowdfundCollection(stubWithStandard(wrong)); + expect(r.valid).toBe(false); + expect(r.errors).toContain('Missing "Crowdfund" standard'); + }); + } + }); + describe('validatePaymentRequestCollection', () => { for (const wrong of otherStandards('PaymentRequest')) { it(`rejects a "${wrong}" collection`, () => { diff --git a/packages/bitbadgesjs-sdk/src/core/crowdfunds.spec.ts b/packages/bitbadgesjs-sdk/src/core/crowdfunds.spec.ts new file mode 100644 index 0000000000..0ebaa03f18 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/crowdfunds.spec.ts @@ -0,0 +1,362 @@ +/** + * Tests for crowdfunds.ts — Crowdfund protocol validator. + * + * A valid Crowdfund needs: + * - standards includes "Crowdfund" + * - validTokenIds = [{1,2}] (refund + progress tokens) + * - 4+ approvals: deposit-refund (Mint->All, token 1), + * deposit-progress (Mint->crowdfunder, token 2), + * success (Mint->burn, mustOwnTokens), + * refund (!Mint->burn, token 1, mustOwnTokens) + * - Deposit paths use allowAmountScaling + overridesFromOutgoing + * - Success/refund use overrideFromWithApproverAddress + * - Same denom across all coinTransfers + */ + +import { validateCrowdfundCollection, doesCollectionFollowCrowdfundProtocol } from './crowdfunds.js'; + +const BURN = 'bb1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7gvmv'; +const CROWDFUNDER = 'bb1crowdfunder'; + +const makeDepositRefund = () => ({ + approvalId: 'deposit-refund', + fromListId: 'Mint', + toListId: 'All', + initiatedByListId: 'All', + transferTimes: [{ start: 1n, end: 1000n }], + tokenIds: [{ start: 1n, end: 1n }], + approvalCriteria: { + overridesFromOutgoingApprovals: true, + requireToEqualsInitiatedBy: true, + coinTransfers: [{ coins: [{ denom: 'ubadge', amount: 100n }] }], + predeterminedBalances: { + incrementedBalances: { + allowAmountScaling: true + } + } + } +}); + +const makeDepositProgress = () => ({ + approvalId: 'deposit-progress', + fromListId: 'Mint', + toListId: CROWDFUNDER, + initiatedByListId: 'All', + transferTimes: [{ start: 1n, end: 1000n }], + tokenIds: [{ start: 2n, end: 2n }], + approvalCriteria: { + overridesFromOutgoingApprovals: true, + coinTransfers: [{ coins: [{ denom: 'ubadge', amount: 100n }] }], + predeterminedBalances: { + incrementedBalances: { + allowAmountScaling: true + } + } + } +}); + +const makeSuccess = () => ({ + approvalId: 'success', + fromListId: 'Mint', + toListId: BURN, + initiatedByListId: CROWDFUNDER, + transferTimes: [{ start: 1001n, end: 2000n }], + tokenIds: [{ start: 2n, end: 2n }], + approvalCriteria: { + coinTransfers: [ + { + overrideFromWithApproverAddress: true, + coins: [{ denom: 'ubadge', amount: 1000n }] + } + ], + mustOwnTokens: [ + { + tokenIds: [{ start: 2n, end: 2n }], + amountRange: { start: 100n, end: 100n } + } + ] + } +}); + +const makeRefund = () => ({ + approvalId: 'refund', + fromListId: '!Mint', + toListId: BURN, + initiatedByListId: 'All', + transferTimes: [{ start: 2001n, end: 3000n }], + tokenIds: [{ start: 1n, end: 1n }], + approvalCriteria: { + coinTransfers: [ + { + overrideFromWithApproverAddress: true, + overrideToWithInitiator: true, + coins: [{ denom: 'ubadge', amount: 1n }] + } + ], + mustOwnTokens: [ + { + tokenIds: [{ start: 2n, end: 2n }], + amountRange: { start: 1n, end: 100n } + } + ] + } +}); + +const makeValidCollection = (): any => ({ + standards: ['Crowdfund'], + validTokenIds: [{ start: 1n, end: 2n }], + collectionApprovals: [makeDepositRefund(), makeDepositProgress(), makeSuccess(), makeRefund()], + collectionPermissions: { + canDeleteCollection: [{}], + canUpdateCollectionApprovals: [{}], + canUpdateValidTokenIds: [{}], + canUpdateStandards: [{}] + } +}); + +describe('validateCrowdfundCollection — happy path', () => { + it('accepts a fully-formed crowdfund', () => { + const r = validateCrowdfundCollection(makeValidCollection()); + expect(r.errors).toEqual([]); + expect(r.valid).toBe(true); + expect(r.details?.crowdfunderAddress).toBe(CROWDFUNDER); + expect(r.details?.depositDenom).toBe('ubadge'); + expect(r.details?.deadlineTime).toBe(1000n); + expect(r.details?.goalAmount).toBe(100n); + }); + + it('doesCollectionFollowCrowdfundProtocol returns true', () => { + expect(doesCollectionFollowCrowdfundProtocol(makeValidCollection())).toBe(true); + }); +}); + +describe('validateCrowdfundCollection — standards', () => { + it('rejects missing Crowdfund standard', () => { + const c = makeValidCollection(); + c.standards = []; + expect(validateCrowdfundCollection(c).errors).toContain('Missing "Crowdfund" standard'); + }); +}); + +describe('validateCrowdfundCollection — validTokenIds', () => { + it('rejects [{1,1}] (missing progress token 2)', () => { + const c = makeValidCollection(); + c.validTokenIds = [{ start: 1n, end: 1n }]; + expect(validateCrowdfundCollection(c).errors).toContain( + 'validTokenIds must be [{start: 1, end: 2}] (refund + progress tokens)' + ); + }); + + it('rejects wrong start', () => { + const c = makeValidCollection(); + c.validTokenIds = [{ start: 0n, end: 2n }]; + expect(validateCrowdfundCollection(c).errors).toContain( + 'validTokenIds must be [{start: 1, end: 2}] (refund + progress tokens)' + ); + }); + + it('rejects empty validTokenIds', () => { + const c = makeValidCollection(); + c.validTokenIds = []; + expect(validateCrowdfundCollection(c).errors).toContain( + 'validTokenIds must be [{start: 1, end: 2}] (refund + progress tokens)' + ); + }); +}); + +describe('validateCrowdfundCollection — permissions warnings', () => { + it('warns when canDeleteCollection is empty (not frozen)', () => { + const c = makeValidCollection(); + c.collectionPermissions.canDeleteCollection = []; + const r = validateCrowdfundCollection(c); + expect(r.warnings.some((w) => w.includes('canDeleteCollection'))).toBe(true); + }); + + it('warns for all 4 permission fields when missing', () => { + const c = makeValidCollection(); + c.collectionPermissions = {}; + const r = validateCrowdfundCollection(c); + expect(r.warnings.length).toBeGreaterThanOrEqual(4); + }); +}); + +describe('validateCrowdfundCollection — approval count', () => { + it('rejects when fewer than 4 approvals', () => { + const c = makeValidCollection(); + c.collectionApprovals = [makeDepositRefund()]; + const r = validateCrowdfundCollection(c); + expect(r.valid).toBe(false); + expect(r.errors.some((e) => e.includes('Expected at least 4 approvals'))).toBe(true); + }); +}); + +describe('validateCrowdfundCollection — missing approval types', () => { + it('rejects when deposit-refund missing', () => { + const c = makeValidCollection(); + c.collectionApprovals = [makeDepositProgress(), makeSuccess(), makeRefund(), makeRefund()]; + const r = validateCrowdfundCollection(c); + expect(r.errors.some((e) => e.includes('Missing deposit-refund approval'))).toBe(true); + }); + + it('rejects when deposit-progress missing', () => { + const c = makeValidCollection(); + c.collectionApprovals = [makeDepositRefund(), makeSuccess(), makeRefund(), makeRefund()]; + const r = validateCrowdfundCollection(c); + expect(r.errors.some((e) => e.includes('Missing deposit-progress approval'))).toBe(true); + }); + + it('rejects when success approval missing (no mustOwnTokens)', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].approvalCriteria.mustOwnTokens = []; + const r = validateCrowdfundCollection(c); + expect(r.errors.some((e) => e.includes('Missing success approval'))).toBe(true); + }); + + it('rejects when refund missing', () => { + const c = makeValidCollection(); + c.collectionApprovals[3].approvalCriteria.mustOwnTokens = []; + const r = validateCrowdfundCollection(c); + expect(r.errors.some((e) => e.includes('Missing refund approval'))).toBe(true); + }); +}); + +describe('validateCrowdfundCollection — deposit-refund rules', () => { + it('rejects when overridesFromOutgoingApprovals false', () => { + const c = makeValidCollection(); + c.collectionApprovals[0].approvalCriteria.overridesFromOutgoingApprovals = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Deposit-refund: overridesFromOutgoingApprovals must be true'); + }); + + it('rejects when requireToEqualsInitiatedBy false', () => { + const c = makeValidCollection(); + c.collectionApprovals[0].approvalCriteria.requireToEqualsInitiatedBy = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Deposit-refund: requireToEqualsInitiatedBy must be true'); + }); + + it('rejects when allowAmountScaling false', () => { + const c = makeValidCollection(); + c.collectionApprovals[0].approvalCriteria.predeterminedBalances.incrementedBalances.allowAmountScaling = false; + expect(validateCrowdfundCollection(c).errors).toContain('Deposit-refund: allowAmountScaling must be true'); + }); + + it('rejects when coinTransfers is missing on deposit-refund', () => { + const c = makeValidCollection(); + c.collectionApprovals[0].approvalCriteria.coinTransfers = []; + expect(validateCrowdfundCollection(c).errors).toContain('Deposit-refund: must have coinTransfers'); + }); +}); + +describe('validateCrowdfundCollection — deposit-progress rules', () => { + it('rejects when overridesFromOutgoingApprovals false', () => { + const c = makeValidCollection(); + c.collectionApprovals[1].approvalCriteria.overridesFromOutgoingApprovals = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Deposit-progress: overridesFromOutgoingApprovals must be true'); + }); + + it('rejects when allowAmountScaling false', () => { + const c = makeValidCollection(); + c.collectionApprovals[1].approvalCriteria.predeterminedBalances.incrementedBalances.allowAmountScaling = false; + expect(validateCrowdfundCollection(c).errors).toContain('Deposit-progress: allowAmountScaling must be true'); + }); +}); + +describe('validateCrowdfundCollection — success rules', () => { + it('rejects when initiatedByListId does not match crowdfunder', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].initiatedByListId = 'bb1someoneelse'; + expect(validateCrowdfundCollection(c).errors).toContain('Success: initiatedByListId must be crowdfunder address'); + }); + + it('rejects when overrideFromWithApproverAddress=false on success', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].approvalCriteria.coinTransfers[0].overrideFromWithApproverAddress = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Success: coinTransfer must use overrideFromWithApproverAddress=true (escrow payout)'); + }); + + it('rejects when mustOwnTokens checks token != 2', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].approvalCriteria.mustOwnTokens[0].tokenIds = [{ start: 1n, end: 1n }]; + expect(validateCrowdfundCollection(c).errors).toContain( + 'Success: mustOwnTokens must check token 2 (progress token)' + ); + }); + + it('rejects when goal amount is 0', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].approvalCriteria.mustOwnTokens[0].amountRange.start = 0n; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Success: mustOwnTokens amountRange.start must be > 0 (goal amount)'); + }); +}); + +describe('validateCrowdfundCollection — refund rules', () => { + it('rejects when overrideFromWithApproverAddress=false on refund', () => { + const c = makeValidCollection(); + c.collectionApprovals[3].approvalCriteria.coinTransfers[0].overrideFromWithApproverAddress = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Refund: coinTransfer must use overrideFromWithApproverAddress=true'); + }); + + it('rejects when overrideToWithInitiator=false on refund', () => { + const c = makeValidCollection(); + c.collectionApprovals[3].approvalCriteria.coinTransfers[0].overrideToWithInitiator = false; + expect( + validateCrowdfundCollection(c).errors + ).toContain('Refund: coinTransfer must use overrideToWithInitiator=true (pay back contributor)'); + }); + + it('rejects refund.mustOwnTokens checking wrong token', () => { + const c = makeValidCollection(); + c.collectionApprovals[3].approvalCriteria.mustOwnTokens[0].tokenIds = [{ start: 1n, end: 1n }]; + expect(validateCrowdfundCollection(c).errors).toContain( + 'Refund: mustOwnTokens must check token 2 (progress token)' + ); + }); +}); + +describe('validateCrowdfundCollection — denom cross-check', () => { + it('rejects when success denom differs from deposit denom', () => { + const c = makeValidCollection(); + c.collectionApprovals[2].approvalCriteria.coinTransfers[0].coins[0].denom = 'uusdc'; + expect(validateCrowdfundCollection(c).errors).toContain('Success denom must match deposit denom'); + }); + + it('rejects when refund denom differs from deposit denom', () => { + const c = makeValidCollection(); + c.collectionApprovals[3].approvalCriteria.coinTransfers[0].coins[0].denom = 'uusdc'; + expect(validateCrowdfundCollection(c).errors).toContain('Refund denom must match deposit denom'); + }); + + it('accepts when all three denoms match', () => { + expect(validateCrowdfundCollection(makeValidCollection()).valid).toBe(true); + }); +}); + +describe('validateCrowdfundCollection — details output', () => { + it('populates details when valid', () => { + const r = validateCrowdfundCollection(makeValidCollection()); + expect(r.details).toBeDefined(); + expect(r.details?.crowdfunderAddress).toBe(CROWDFUNDER); + expect(r.details?.depositDenom).toBe('ubadge'); + expect(r.details?.deadlineTime).toBe(1000n); + expect(r.details?.goalAmount).toBe(100n); + }); + + it('returns empty details when approvals missing (early-return path)', () => { + const c = makeValidCollection(); + c.collectionApprovals = c.collectionApprovals.slice(0, 2); + const r = validateCrowdfundCollection(c); + expect(r.valid).toBe(false); + expect(r.details).toEqual({}); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/core/crowdfunds.ts b/packages/bitbadgesjs-sdk/src/core/crowdfunds.ts new file mode 100644 index 0000000000..55d534c4f4 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/crowdfunds.ts @@ -0,0 +1,430 @@ +import { iCollectionDoc } from '@/api-indexer/docs-types/interfaces.js'; +import { GO_MAX_UINT_64 } from '@/common/math.js'; +import type { iCollectionApproval } from '@/interfaces/types/approvals.js'; + +const BURN_ADDRESS = 'bb1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7gvmv'; +const MAX_UINT64 = '18446744073709551615'; + +export interface CrowdfundValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + details?: { + depositDenom?: string; + deadlineTime?: bigint; + crowdfunderAddress?: string; + goalAmount?: bigint; + }; +} + +export const validateCrowdfundCollection = (collection: Readonly>): CrowdfundValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + const details: CrowdfundValidationResult['details'] = {}; + + // 1. Standard + if (!collection.standards?.includes('Crowdfund')) errors.push('Missing "Crowdfund" standard'); + + // 2. validTokenIds = [{1,2}] + const vt = collection.validTokenIds; + if (!vt || vt.length !== 1 || vt[0].start !== 1n || vt[0].end !== 2n) { + errors.push('validTokenIds must be [{start: 1, end: 2}] (refund + progress tokens)'); + } + + // 3. Permissions frozen + const perms = collection.collectionPermissions; + const checkFrozen = (field: string, perm: any[]) => { + if (!perm || perm.length === 0) { + warnings.push(`Permission ${field} is not frozen (should be for crowdfunds)`); + } + }; + checkFrozen('canDeleteCollection', perms.canDeleteCollection); + checkFrozen('canUpdateCollectionApprovals', perms.canUpdateCollectionApprovals); + checkFrozen('canUpdateValidTokenIds', perms.canUpdateValidTokenIds); + checkFrozen('canUpdateStandards', perms.canUpdateStandards); + + // 4. Need 4-5 approvals + const approvals = collection.collectionApprovals; + if (approvals.length < 4) { + errors.push(`Expected at least 4 approvals (deposit-refund, deposit-progress, success, refund), found ${approvals.length}`); + return { valid: false, errors, warnings, details }; + } + + // Find each approval type + const depositRefund = approvals.find( + (a) => a.fromListId === 'Mint' && a.toListId === 'All' && a.tokenIds?.[0]?.start === 1n && a.tokenIds?.[0]?.end === 1n + ); + const depositProgress = approvals.find( + (a) => a.fromListId === 'Mint' && a.toListId !== 'All' && a.toListId !== BURN_ADDRESS && a.tokenIds?.[0]?.start === 2n + ); + const success = approvals.find( + (a) => a.fromListId === 'Mint' && a.toListId === BURN_ADDRESS && a.approvalCriteria?.mustOwnTokens?.length + ); + // Refund: !Mint -> burn, with mustOwnTokens + const refund = approvals.find( + (a) => + a.fromListId !== 'Mint' && a.toListId === BURN_ADDRESS && a.approvalCriteria?.mustOwnTokens?.length && a.tokenIds?.[0]?.start === 1n + ); + + if (!depositRefund) errors.push('Missing deposit-refund approval (Mint->All, token 1)'); + if (!depositProgress) errors.push('Missing deposit-progress approval (Mint->crowdfunder, token 2)'); + if (!success) errors.push('Missing success approval (Mint->burn, with mustOwnTokens)'); + if (!refund) errors.push('Missing refund approval (!Mint->burn, token 1, with mustOwnTokens)'); + + if (!depositRefund || !depositProgress || !success || !refund) { + return { valid: false, errors, warnings, details }; + } + + // Extract details + details.crowdfunderAddress = depositProgress.toListId; + details.depositDenom = depositRefund.approvalCriteria?.coinTransfers?.[0]?.coins?.[0]?.denom + ? String(depositRefund.approvalCriteria.coinTransfers[0].coins[0].denom) + : undefined; + details.deadlineTime = depositRefund.transferTimes?.[0]?.end; + details.goalAmount = success.approvalCriteria?.mustOwnTokens?.[0]?.amountRange?.start; + + // Validate deposit-refund + // overridesFromOutgoingApprovals is required by the Mint approval standard. + // overridesToIncomingApprovals is NOT required — the recipient + // auto-approves via defaultBalances.autoApproveAllIncomingTransfers and/or + // autoApproveSelfInitiatedIncomingTransfers, so the collection-level + // incoming override would be redundant. Setting it to true is allowed + // but not required; setting it to false is the preferred default. + const drCriteria = depositRefund.approvalCriteria; + if (!drCriteria?.overridesFromOutgoingApprovals) errors.push('Deposit-refund: overridesFromOutgoingApprovals must be true'); + if (!drCriteria?.requireToEqualsInitiatedBy) errors.push('Deposit-refund: requireToEqualsInitiatedBy must be true'); + const drIb = drCriteria?.predeterminedBalances?.incrementedBalances; + if (!drIb?.allowAmountScaling) errors.push('Deposit-refund: allowAmountScaling must be true'); + if (!drCriteria?.coinTransfers?.length) errors.push('Deposit-refund: must have coinTransfers'); + + // Validate deposit-progress + // Same incoming-override rationale as deposit-refund. + const dpCriteria = depositProgress.approvalCriteria; + if (!dpCriteria?.overridesFromOutgoingApprovals) errors.push('Deposit-progress: overridesFromOutgoingApprovals must be true'); + const dpIb = dpCriteria?.predeterminedBalances?.incrementedBalances; + if (!dpIb?.allowAmountScaling) errors.push('Deposit-progress: allowAmountScaling must be true'); + + // Validate success + const sCriteria = success.approvalCriteria; + if (success.initiatedByListId !== details.crowdfunderAddress) { + errors.push('Success: initiatedByListId must be crowdfunder address'); + } + if (!sCriteria?.coinTransfers?.[0]?.overrideFromWithApproverAddress) { + errors.push('Success: coinTransfer must use overrideFromWithApproverAddress=true (escrow payout)'); + } + const sMot = sCriteria?.mustOwnTokens?.[0]; + if (sMot && sMot.tokenIds?.[0]?.start !== 2n) { + errors.push('Success: mustOwnTokens must check token 2 (progress token)'); + } + if (sMot && (sMot.amountRange?.start ?? 0n) <= 0n) { + errors.push('Success: mustOwnTokens amountRange.start must be > 0 (goal amount)'); + } + + // Validate refund + const rCriteria = refund.approvalCriteria; + if (!rCriteria?.coinTransfers?.[0]?.overrideFromWithApproverAddress) { + errors.push('Refund: coinTransfer must use overrideFromWithApproverAddress=true'); + } + if (!rCriteria?.coinTransfers?.[0]?.overrideToWithInitiator) { + errors.push('Refund: coinTransfer must use overrideToWithInitiator=true (pay back contributor)'); + } + const rMot = rCriteria?.mustOwnTokens?.[0]; + if (rMot && rMot.tokenIds?.[0]?.start !== 2n) { + errors.push('Refund: mustOwnTokens must check token 2 (progress token)'); + } + + // Cross-check: denoms match + const successDenom = sCriteria?.coinTransfers?.[0]?.coins?.[0]?.denom; + const refundDenom = rCriteria?.coinTransfers?.[0]?.coins?.[0]?.denom; + if (details.depositDenom && successDenom && String(successDenom) !== details.depositDenom) { + errors.push('Success denom must match deposit denom'); + } + if (details.depositDenom && refundDenom && String(refundDenom) !== details.depositDenom) { + errors.push('Refund denom must match deposit denom'); + } + + return { valid: errors.length === 0, errors, warnings, details }; +}; + +export const doesCollectionFollowCrowdfundProtocol = (collection: Readonly>): boolean => { + return validateCrowdfundCollection(collection).valid; +}; + +// ── End-user helpers (lifted from FE CrowdfundView) ─────────────────────── + +export type CrowdfundStatus = 'active' | 'funded' | 'goal-met-pending-settle' | 'expired-refunding'; + +export interface CrowdfundDetails { + depositRefundApproval: iCollectionApproval; + depositProgressApproval: iCollectionApproval; + successApproval: iCollectionApproval; + refundApproval: iCollectionApproval; + crowdfunderAddress: string; + goalAmount: bigint; + depositDenom: string; + deadlineTime: bigint; +} + +/** + * Split a crowdfund's 4 approvals into deposit-refund / deposit-progress / + * success / refund + extract config. Returns null on shape mismatch. + * Lifted from FE `CrowdfundView.extractDetails`. + */ +export function extractCrowdfundDetails( + approvals: ReadonlyArray> +): CrowdfundDetails | null { + const depositRefund = approvals.find( + (a) => a.fromListId === 'Mint' && a.toListId === 'All' && (a.approvalCriteria?.coinTransfers?.length ?? 0) > 0 + ); + const depositProgress = approvals.find( + (a) => + a.fromListId === 'Mint' && + a.toListId !== 'All' && + a.toListId !== BURN_ADDRESS && + (a.approvalCriteria?.coinTransfers?.length ?? 0) === 0 + ); + const success = approvals.find( + (a) => + a.fromListId === 'Mint' && + a.toListId === BURN_ADDRESS && + (a.approvalCriteria?.mustOwnTokens?.length ?? 0) > 0 + ); + const refund = approvals.find( + (a) => + a.fromListId === '!Mint' && + a.toListId === BURN_ADDRESS && + (a.approvalCriteria?.coinTransfers?.length ?? 0) > 0 + ); + if (!depositRefund || !depositProgress || !success || !refund) return null; + + return { + depositRefundApproval: depositRefund, + depositProgressApproval: depositProgress, + successApproval: success, + refundApproval: refund, + crowdfunderAddress: depositProgress.toListId ?? '', + goalAmount: BigInt(success.approvalCriteria?.mustOwnTokens?.[0]?.amountRange?.start ?? 0), + depositDenom: String(depositRefund.approvalCriteria?.coinTransfers?.[0]?.coins?.[0]?.denom ?? ''), + deadlineTime: BigInt(depositRefund.transferTimes?.[0]?.end ?? 0) + }; +} + +/** Compute status from indexer/standardsInfo if available, else from on-chain state + clock. */ +export function deriveCrowdfundStatus(deadlineMs: bigint, raised: bigint, goal: bigint): CrowdfundStatus { + const now = BigInt(Date.now()); + if (raised >= goal) return now > deadlineMs ? 'funded' : 'goal-met-pending-settle'; + if (now > deadlineMs) return 'expired-refunding'; + return 'active'; +} + +// ── Msg builders ────────────────────────────────────────────────────────── + +interface MsgEnvelope { + typeUrl: '/tokenization.MsgTransferTokens'; + value: Record; +} + +const fullOwnershipTimes = [{ start: '1', end: MAX_UINT64 }]; + +/** + * Build the 2-msg contribute tx. Pipe to `bb deploy` — single tx, two + * transfers inside it (mint token-1 to contributor + mint token-2 to + * crowdfunder), each fired via its own deposit approval. + */ +export function buildContributeCrowdfundTx( + creator: string, + collectionId: string, + details: CrowdfundDetails, + amount: bigint +): { messages: [MsgEnvelope] } { + if (amount <= 0n) throw new Error('buildContributeCrowdfundTx: --amount must be > 0'); + return { + messages: [ + { + typeUrl: '/tokenization.MsgTransferTokens', + value: { + creator, + collectionId: String(collectionId), + transfers: [ + { + from: 'Mint', + toAddresses: [creator], + balances: [ + { + amount: amount.toString(), + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: fullOwnershipTimes + } + ], + prioritizedApprovals: [ + { + approvalId: details.depositRefundApproval.approvalId, + approvalLevel: 'collection', + approverAddress: '', + version: '0' + } + ], + onlyCheckPrioritizedCollectionApprovals: true, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + }, + { + from: 'Mint', + toAddresses: [details.crowdfunderAddress], + balances: [ + { + amount: amount.toString(), + tokenIds: [{ start: '2', end: '2' }], + ownershipTimes: fullOwnershipTimes + } + ], + prioritizedApprovals: [ + { + approvalId: details.depositProgressApproval.approvalId, + approvalLevel: 'collection', + approverAddress: '', + version: '0' + } + ], + onlyCheckPrioritizedCollectionApprovals: true, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + } + ] + } + } + ] + }; +} + +/** + * Build the crowdfunder-side withdraw tx (only callable when goal met). + * Fires the success approval to drain escrow + burns the crowdfunder's + * accumulated progress tokens. 2-msg. + */ +export function buildWithdrawCrowdfundTx( + creator: string, + collectionId: string, + details: CrowdfundDetails, + raised: bigint, + burnApprovalId?: string +): { messages: MsgEnvelope[] } { + const messages: MsgEnvelope[] = [ + { + typeUrl: '/tokenization.MsgTransferTokens', + value: { + creator, + collectionId: String(collectionId), + transfers: [ + { + from: 'Mint', + toAddresses: [BURN_ADDRESS], + balances: [ + { + amount: raised.toString(), + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: fullOwnershipTimes + } + ], + prioritizedApprovals: [ + { + approvalId: details.successApproval.approvalId, + approvalLevel: 'collection', + approverAddress: '', + version: '0' + } + ], + onlyCheckPrioritizedCollectionApprovals: true, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + } + ] + } + }, + { + typeUrl: '/tokenization.MsgTransferTokens', + value: { + creator, + collectionId: String(collectionId), + transfers: [ + { + from: creator, + toAddresses: [BURN_ADDRESS], + balances: [ + { + amount: raised.toString(), + tokenIds: [{ start: '2', end: '2' }], + ownershipTimes: fullOwnershipTimes + } + ], + prioritizedApprovals: burnApprovalId + ? [ + { + approvalId: burnApprovalId, + approvalLevel: 'collection', + approverAddress: '', + version: '0' + } + ] + : [], + onlyCheckPrioritizedCollectionApprovals: !!burnApprovalId, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + } + ] + } + } + ]; + return { messages }; +} + +/** + * Build the contributor-side refund tx — fires the refund approval to + * pull funds back out of escrow. Single MsgTransferTokens. + */ +export function buildRefundCrowdfundMsg( + creator: string, + collectionId: string, + details: CrowdfundDetails, + amount: bigint +): MsgEnvelope { + if (amount <= 0n) throw new Error('buildRefundCrowdfundMsg: --amount must be > 0'); + return { + typeUrl: '/tokenization.MsgTransferTokens', + value: { + creator, + collectionId: String(collectionId), + transfers: [ + { + from: creator, + toAddresses: [BURN_ADDRESS], + balances: [ + { + amount: amount.toString(), + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: fullOwnershipTimes + } + ], + prioritizedApprovals: [ + { + approvalId: details.refundApproval.approvalId, + approvalLevel: 'collection', + approverAddress: '', + version: '0' + } + ], + onlyCheckPrioritizedCollectionApprovals: true, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + } + ] + } + }; +} + +void GO_MAX_UINT_64; // keep import; future use for timeline bounds diff --git a/packages/bitbadgesjs-sdk/src/core/design-decisions/standards.ts b/packages/bitbadgesjs-sdk/src/core/design-decisions/standards.ts index e9035a213e..1ff24422a0 100644 --- a/packages/bitbadgesjs-sdk/src/core/design-decisions/standards.ts +++ b/packages/bitbadgesjs-sdk/src/core/design-decisions/standards.ts @@ -22,6 +22,7 @@ import { doesCollectionFollowBountyProtocol } from '../bounties.js'; import { doesCollectionFollowPaymentRequestProtocol } from '../payment-requests.js'; import { doesCollectionFollowAuctionProtocol } from '../auctions.js'; import { doesCollectionFollowInvoiceProtocol } from '../invoices.js'; +import { doesCollectionFollowCrowdfundProtocol } from '../crowdfunds.js'; import { doesCollectionFollowQuestProtocol } from '../quests.js'; import { doesCollectionFollowProductProtocol, doesCollectionFollowProductCatalogProtocol } from '../products.js'; @@ -38,6 +39,7 @@ const ENTRIES: StandardEntry[] = [ { code: 'design.standards.payment_request', label: 'PaymentRequest protocol', standard: 'PaymentRequest', check: (c) => doesCollectionFollowPaymentRequestProtocol(c) }, { code: 'design.standards.auction', label: 'Auction protocol', standard: 'Auctions', check: (c) => doesCollectionFollowAuctionProtocol(c) }, { code: 'design.standards.invoice', label: 'Invoice protocol', standard: 'Invoices', check: (c) => doesCollectionFollowInvoiceProtocol(c) }, + { code: 'design.standards.crowdfund', label: 'Crowdfund protocol', standard: 'Crowdfunds', check: (c) => doesCollectionFollowCrowdfundProtocol(c) }, { code: 'design.standards.quest', label: 'Quest protocol', standard: 'Quests', check: (c) => doesCollectionFollowQuestProtocol(c) }, { code: 'design.standards.product', label: 'Product protocol', standard: 'Products', check: (c) => doesCollectionFollowProductProtocol(c) }, { code: 'design.standards.product_catalog', label: 'Product Catalog protocol', standard: 'ProductCatalogs', check: (c) => doesCollectionFollowProductCatalogProtocol(c) } diff --git a/packages/bitbadgesjs-sdk/src/core/index.ts b/packages/bitbadgesjs-sdk/src/core/index.ts index 3f5a6d5a37..f290141e80 100644 --- a/packages/bitbadgesjs-sdk/src/core/index.ts +++ b/packages/bitbadgesjs-sdk/src/core/index.ts @@ -21,6 +21,7 @@ export * from './bids.js'; export * from './quests.js'; export * from './invoices.js'; export * from './products.js'; +export * from './crowdfunds.js'; export * from './scheduled-payments.js'; export * from './ibc-wrappers.js'; diff --git a/packages/bitbadgesjs-sdk/src/core/review-ux/metadata.ts b/packages/bitbadgesjs-sdk/src/core/review-ux/metadata.ts index bc73e834f2..88c7845661 100644 --- a/packages/bitbadgesjs-sdk/src/core/review-ux/metadata.ts +++ b/packages/bitbadgesjs-sdk/src/core/review-ux/metadata.ts @@ -29,7 +29,7 @@ const KNOWN_SYSTEM_APPROVAL_PREFIXES = [ 'smart-token-unbacking', 'quests-approval', 'credit-scaled', - // AuctionRegistry / PredictionMarketRegistry / + // AuctionRegistry / PredictionMarketRegistry / CrowdfundRegistry / // VaultApprovalRegistry approvals all use the prefix- pattern. 'auction-mint-to-winner', 'auction-burn', @@ -40,6 +40,11 @@ const KNOWN_SYSTEM_APPROVAL_PREFIXES = [ 'pm-settle-push-yes', 'pm-settle-push-no', 'pm-transfer', + 'crowdfund-deposit-refund', + 'crowdfund-deposit-progress', + 'crowdfund-success', + 'crowdfund-refund', + 'crowdfund-burn', 'vault-deposit', 'vault-withdraw', 'vault-internal-transfer', diff --git a/packages/bitbadgesjs-sdk/src/core/review-ux/skills.ts b/packages/bitbadgesjs-sdk/src/core/review-ux/skills.ts index f356a76455..fffdb23d77 100644 --- a/packages/bitbadgesjs-sdk/src/core/review-ux/skills.ts +++ b/packages/bitbadgesjs-sdk/src/core/review-ux/skills.ts @@ -14,6 +14,7 @@ import { getApprovals, getAllApprovals } from './shared.js'; import { doesCollectionFollowSubscriptionProtocol } from '../subscriptions.js'; import { doesCollectionFollowQuestProtocol } from '../quests.js'; import { doesCollectionFollowBountyProtocol } from '../bounties.js'; +import { doesCollectionFollowCrowdfundProtocol } from '../crowdfunds.js'; import { doesCollectionFollowAuctionProtocol } from '../auctions.js'; import { doesCollectionFollowProductProtocol } from '../products.js'; @@ -41,6 +42,12 @@ const PROTOCOL_COPY: Record = { detail: 'This collection has the Bounty standard but does not meet all the requirements for a valid bounty.', recommendation: 'Fix the bounty setup to follow the bounty protocol requirements' }, + crowdfund: { + title: 'Collection does not follow the crowdfund protocol', + detail: + 'This collection has the Crowdfund standard but does not meet all the requirements for a valid crowdfund.', + recommendation: 'Fix the crowdfund setup to follow the crowdfund protocol requirements' + }, auction: { title: 'Collection does not follow the auction protocol', detail: 'This collection has the Auction standard but does not meet all the requirements for a valid auction.', @@ -128,6 +135,7 @@ export const skillChecks: UxCheck[] = [ { standard: 'Subscriptions', check: doesCollectionFollowSubscriptionProtocol as any, key: 'subscription' }, { standard: 'Quests', check: doesCollectionFollowQuestProtocol as any, key: 'quest' }, { standard: 'Bounty', check: doesCollectionFollowBountyProtocol as any, key: 'bounty' }, + { standard: 'Crowdfund', check: doesCollectionFollowCrowdfundProtocol as any, key: 'crowdfund' }, { standard: 'Auction', check: doesCollectionFollowAuctionProtocol as any, key: 'auction' }, { standard: 'Products', check: doesCollectionFollowProductProtocol as any, key: 'product_catalog' } ]; @@ -247,7 +255,7 @@ export const skillChecks: UxCheck[] = [ // TxTimelineContext and only attached to the Msg at the final // broadcast step in CreateTxMsgUniversalUpdateCollection.tsx. // That meant the warning fired on every quest / subscription / - // auction form no matter what the user set. Since the + // crowdfund / auction form no matter what the user set. Since the // mint escrow can also be topped up at any time post-creation, the // check was noise without a reliable signal. Removed. From c94bac75ad060e77c78b904088851fcd61a93a18 Mon Sep 17 00:00:00 2001 From: Trevor Miller Date: Mon, 22 Jun 2026 09:59:38 -0500 Subject: [PATCH 2/2] chore(sdk): release bitbadges 0.42.0 (re-add Crowdfund) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/bitbadgesjs-sdk/package-lock.json | 4 ++-- packages/bitbadgesjs-sdk/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bitbadgesjs-sdk/package-lock.json b/packages/bitbadgesjs-sdk/package-lock.json index 49cea08ddd..77487e6f90 100644 --- a/packages/bitbadgesjs-sdk/package-lock.json +++ b/packages/bitbadgesjs-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitbadges", - "version": "0.41.1", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitbadges", - "version": "0.41.1", + "version": "0.42.0", "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^1.10.1", diff --git a/packages/bitbadgesjs-sdk/package.json b/packages/bitbadgesjs-sdk/package.json index 2c504afb8a..f4fccd8c70 100644 --- a/packages/bitbadgesjs-sdk/package.json +++ b/packages/bitbadgesjs-sdk/package.json @@ -1,7 +1,7 @@ { "name": "bitbadges", "description": "BitBadges — programmable tokens on Cosmos. SDK, CLI, and builder tools for humans and agents. (Previously published as 'bitbadgesjs-sdk'.)", - "version": "0.41.2", + "version": "0.42.0", "license": "MIT", "keywords": [ "bitbadges",