Files
eranos/src/lib/formatCampaignAmount.ts
T
Chad Curtis 935c121bab Implement Campaign kind 33863
Replaces every kind 30223 surface with kind 33863 -- the self-authored
fundraising campaign with a single `w` Bitcoin wallet endpoint. Hard
cutoff: no migration, no dual-read, no legacy support.

Schema (src/lib/campaign.ts):
- `CAMPAIGN_KIND` constant bumped 30223 -> 33863.
- New `CampaignWallet` type with `onchain` (`bc1q`/`bc1p`) and `sp`
  (`sp1`) modes, prefix-disambiguated. Bitcoin-mainnet only;
  testnet/regtest/lightning prefixes are rejected at parse time.
- New `parseCampaignWallet()` validates bech32 via bitcoinjs-lib for
  on-chain addresses and shape-checks silent-payment codes.
- `ParsedCampaign` drops `recipients`, `category`, `tags`,
  `location`, `archived`, `image` (-> `banner`), `goalSats` (->
  `goalUsd`). Adds `wallet` and `bannerImeta` (parsed NIP-92).
- `CAMPAIGN_CATEGORIES`, `CampaignCategory`,
  `LEGACY_CAMPAIGN_CATEGORY_ALIASES`, `getCampaignPrimaryTagLabel`,
  `splitDonation`, `minDonationForSplit`, `DonationSplit`,
  `CampaignRecipient` removed.

Publishing (useDonateCampaign):
- Single-output PSBT paying `campaign.wallet.value`.
- Kind 8333 receipt has NO `p` tags -- campaigns are not Nostr-identity
  recipients. `i`, `amount`, `a`, `K`, `alt` only.
- SP campaigns are refused with a clear error directing donors to
  external BIP-352-capable wallets via the on-page QR/copy panel.

Verification (useOnchainZaps.verifyOnchainZap):
- Two modes: identity-recipient (existing `p`-tag derivation) and
  campaign-wallet (match outputs against `campaign.w`). The branch is
  selected by whether the receipt has an `a` tag pointing at a kind
  33863 campaign. SP-targeted receipts are rejected.

Querying (useCampaigns, useAllCampaigns, useCampaignDonations):
- Drop `category`, `recipientPubkeys`, `includeArchived` options.
- `useCampaignDonations` now takes a `ParsedCampaign` and verifies
  every receipt on-chain against the campaign's `w` address before
  counting it. SP campaigns short-circuit to zeros.
- Search drops `location` and `t`-tag branches; title/summary/story
  only.

UI:
- `CampaignCard`, `HeroCampaignSpotlight`, `CampaignsPage`: drop
  recipient counts, archived/category badges, `location`; use
  `banner`. Silent-payment campaigns render a "Private -- totals not
  public" notice instead of progress.
- `CampaignDetailPage`: archive flow replaced with NIP-09 kind 5
  deletion. Drops the multi-beneficiary recipient column. Donate column
  shows the in-app PSBT donate button (on-chain) plus the always-
  available external-wallet QR/copy panel. SP campaigns show the panel
  only -- no in-app donate.
- `CreateCampaignPage`: drops Beneficiaries section, tag input, and
  USD-to-sats conversion. Adds a Wallet field with mode-aware
  validation hint. Goal is integer USD. Banner upload captures NIP-94
  tags and converts to NIP-92 `imeta` at publish.
- `DonateDialog`: collapses ~1200 LOC of split logic into a single-
  output flow. Form -> Confirm -> Success. Logged-out and signer-
  unsupported users are pointed at the external-wallet panel.
- New `CampaignWalletDonatePanel` component (replaces the
  pubkey-derived `BeneficiaryDonateDialog`). QR + copy + open-in-wallet
  for any `bc1`/`sp1` endpoint, with mode-appropriate privacy notice.

Removed:
- `useArchiveCampaign` hook (closure via NIP-09 deletion only).
- `ClaimPage` and its `/claim` route (claim-for-someone-else flow no
  longer applies -- campaigns are self-authored).
- `BeneficiaryDonateDialog.tsx` (replaced by
  `CampaignWalletDonatePanel.tsx`).
- Community-donate synthesis hack in `CommunityDetailPage` (no more
  fabricating a `ParsedCampaign` from community moderators).

NIP.md was updated separately to specify Kind 33863.
2026-05-21 19:04:41 -05:00

48 lines
1.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { satsToUSDWhole } from '@/lib/bitcoin';
/**
* Formats a sats amount into a short, human-readable string.
*
* - `< 10,000` sats — shows the exact number with thousands separators.
* - `10,000 999,999` sats — rounds to the nearest thousand (`12K sats`).
* - `1,000,000 99,999,999` sats — two decimals of millions (`1.23M sats`).
* - `>= 100,000,000` sats (1 BTC) — switches to BTC with two decimals.
*/
export function formatSatsShort(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`;
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`;
if (sats >= 10_000) return `${(sats / 1_000).toFixed(0)}K sats`;
return `${sats.toLocaleString()} sats`;
}
/**
* Renders a sats count as USD (whole dollars) when a BTC price is
* available, falling back to {@link formatSatsShort} otherwise. Used by
* campaign cards and the hero spotlight so totals are consistent across
* the app.
*/
export function formatCampaignAmount(sats: number, btcPrice: number | undefined): string {
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return formatSatsShort(sats);
}
/**
* Formats an integer USD amount (the campaign goal unit per NIP.md Kind
* 33863). Uses thousands separators and a leading `$`. Negative or
* non-finite values render as `$0`.
*/
export function formatUsdGoal(usd: number): string {
if (!Number.isFinite(usd) || usd <= 0) return '$0';
return `$${Math.floor(usd).toLocaleString()}`;
}
/**
* Convert sats to USD using a live BTC/USD price. Returns `undefined` if
* the price isn't available — callers should fall back to the sats
* representation in that case.
*/
export function satsToUsd(sats: number, btcPrice: number | undefined): number | undefined {
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return undefined;
return (sats / 100_000_000) * btcPrice;
}