Campaign progress: source raised total from on-chain address balance

Per-campaign 'raised' was the sum of verified kind 8333 donation receipts:
each receipt's tx was re-fetched and its outputs paying the campaign's `w`
address were summed. That counted only donations whose donor published a
receipt — direct on-chain payments were ignored — and required N `/tx/`
lookups per campaign view.

Source `totalSats` from a single `/address/{w}` lookup against the
configured Esplora endpoint (default: mempool.space) and use
`chain_stats.funded_txo_sum` (lifetime received). Any payment to the
address now counts, and the progress bar does not regress when the
beneficiary spends.

Kind 8333 receipts are still fetched and verified to power the donor list,
donor count, and per-tx breakdown — they just no longer drive the headline
number.

Silent-payment campaigns are unchanged (no observable balance).
`useProfileCampaignStats` and the `SortedByTopGrid` on the profile
campaigns tab switch to the same address-balance source.
This commit is contained in:
Alex Gleason
2026-05-22 18:55:07 -05:00
parent 93108bc00e
commit 7cdeead7b2
4 changed files with 97 additions and 141 deletions
+16 -54
View File
@@ -1,9 +1,7 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Megaphone } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueries } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { Button } from '@/components/ui/button';
@@ -12,10 +10,7 @@ import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import {
extractOnchainZapTxid,
verifyOnchainZap,
} from '@/hooks/useOnchainZaps';
import { fetchAddressData } from '@/lib/bitcoin';
import type { ParsedCampaign } from '@/lib/campaign';
interface ProfileCampaignsTabProps {
@@ -144,68 +139,35 @@ export function ProfileCampaignsTab({
}
/**
* Sorts the visible campaigns by verified sats raised (descending) by
* fanning out one receipts query + per-receipt verification across all
* campaigns at once. Uses `useQueries`, so the hook call count is
* deterministic per render (one queries-tuple, not one hook per campaign)
* and the rules of hooks hold.
* Sorts the visible campaigns by sats raised (descending) by fanning
* out one address-balance query per on-chain campaign. Uses `useQueries`,
* so the hook call count is deterministic per render (one queries-tuple,
* not one hook per campaign) and the rules of hooks hold.
*
* Caches share keys with `useCampaignDonations` so the verifier results
* Caches share keys with `useCampaignDonations` so the balance results
* are reused across the profile and any other view of the same campaign.
*/
function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraApis } = config;
// Only on-chain campaigns can have verifiable totals. SP campaigns sort to 0.
// Only on-chain campaigns can have observable totals. SP campaigns sort to 0.
const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
// Step 1: one receipts query per on-chain campaign.
const receiptsQueries = useQueries({
const balanceQueries = useQueries({
queries: onchain.map((campaign) => ({
queryKey: ['campaign-donations', 'events', campaign.aTag],
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
return nostr.query(
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
{ signal },
);
},
staleTime: 15_000,
queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchAddressData(campaign.wallet!.value, esploraApis, signal),
staleTime: 30_000,
enabled: !!campaign.wallet?.value,
})),
});
// Step 2: dedupe receipts by txid (earliest wins, matching useCampaignDonations).
const verificationInputs: Array<{ aTag: string; wallet: string; event: NostrEvent }> = [];
for (let i = 0; i < onchain.length; i++) {
const campaign = onchain[i];
const wallet = campaign.wallet?.value;
if (!wallet) continue;
const receipts = receiptsQueries[i]?.data ?? [];
const ascending = [...receipts].sort((a, b) => a.created_at - b.created_at);
const seenTxids = new Set<string>();
for (const event of ascending) {
const txid = extractOnchainZapTxid(event);
if (!txid || seenTxids.has(txid)) continue;
seenTxids.add(txid);
verificationInputs.push({ aTag: campaign.aTag, wallet, event });
}
}
const verifications = useQueries({
queries: verificationInputs.map(({ wallet, event }) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, wallet],
queryFn: () => verifyOnchainZap(event, esploraApis, wallet),
staleTime: 60_000,
})),
});
// Step 3: sum verified sats per campaign aTag.
const totalsByCoord = new Map<string, number>();
for (let i = 0; i < verifications.length; i++) {
const { aTag } = verificationInputs[i];
const sats = verifications[i].data?.amountSats ?? 0;
totalsByCoord.set(aTag, (totalsByCoord.get(aTag) ?? 0) + sats);
for (let i = 0; i < onchain.length; i++) {
const sats = balanceQueries[i]?.data?.totalReceived ?? 0;
totalsByCoord.set(onchain[i].aTag, sats);
}
const sorted = [...campaigns].sort(
+55 -26
View File
@@ -4,23 +4,30 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { verifyOnchainZap, extractOnchainZapTxid, type OnchainZapEntry } from '@/hooks/useOnchainZaps';
import { fetchAddressData } from '@/lib/bitcoin';
import type { ParsedCampaign } from '@/lib/campaign';
export interface CampaignDonationStats {
/** Total satoshis pledged across all verified kind 8333 receipts. */
/**
* Total satoshis raised, sourced from the cumulative on-chain amount
* ever received by the campaign's `w` address (`chain_stats.funded_txo_sum`
* from Esplora). This is independent of Nostr donation receipts —
* any payment to the address counts, and beneficiary payouts do not
* reduce the number.
*/
totalSats: number;
/** Number of unique on-chain transactions counted. */
/** Number of unique on-chain transactions counted (from verified receipts). */
txCount: number;
/** Number of unique donor pubkeys. */
/** Number of unique donor pubkeys (from verified receipts). */
donorCount: number;
/** All raw kind 8333 receipts for the campaign, newest first. */
receipts: NostrEvent[];
/** Verified entries (one per unique txid). */
verified: OnchainZapEntry[];
/**
* True while underlying verification queries are still in flight.
* Callers may use this to defer rendering "0 sats raised" until
* the verifier has had a chance to validate the receipts.
* True while underlying queries (address balance + receipt verification)
* are still in flight. Callers may use this to defer rendering
* "0 sats raised" until the data has had a chance to load.
*/
isVerifying: boolean;
}
@@ -28,24 +35,28 @@ export interface CampaignDonationStats {
const EMPTY_RECEIPTS: NostrEvent[] = [];
/**
* Aggregates donation receipts (kind 8333 events) for a campaign and
* **verifies each one on-chain** before counting it toward the campaign
* total.
* Aggregates donation statistics for a campaign.
*
* Per NIP.md §Kind 33863, each receipt:
* The headline number — `totalSats` — comes from a direct balance lookup
* on the campaign's `w` Bitcoin address via the configured Esplora endpoint
* (default: mempool.space). Specifically, it's `chain_stats.funded_txo_sum`,
* the cumulative amount ever sent to the address. This means:
*
* - Targets the campaign via an `a` tag (`33863:<pubkey>:<d>`).
* - Carries an `i bitcoin:tx:<txid>` tag.
* - Carries an `amount <sats>` tag (self-reported, capped at verified).
* - Carries **no `p` tags** — campaigns are not Nostr-identity recipients.
* - Donations are counted whether or not the donor publishes a Nostr
* receipt (kind 8333).
* - The progress bar does not regress when the beneficiary spends from
* the address.
* - Anyone who sends sats to the address contributes to "raised" —
* address reuse trades off security here. Fresh-per-campaign addresses
* (the default "public" wallet source) avoid this entirely.
*
* Verification re-fetches the tx from the configured Esplora endpoint and
* sums the outputs paying the campaign's `w` address. The self-reported
* `amount` is capped at the verified amount.
* Donation receipts (kind 8333) are still fetched and verified on-chain
* to populate the donor list, donor count, and per-tx breakdown shown in
* the UI. They no longer contribute to `totalSats`.
*
* Silent-payment campaigns (`w` starts with `sp1…`) short-circuit to
* zeros — donations to SP campaigns are unlinkable by design and clients
* MUST NOT publish receipts.
* zeros — donations are unlinkable by design, so address balance is
* undefined.
*/
export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
data: CampaignDonationStats;
@@ -58,8 +69,22 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
const aTag = campaign?.aTag;
const wallet = campaign?.wallet;
const isSilentPayment = wallet?.mode === 'sp';
const isOnchain = wallet?.mode === 'onchain';
const walletValue = wallet?.value;
// Step 1: fetch raw receipts. Disabled for SP campaigns.
// Headline number: query the address balance directly from Esplora.
// `totalReceived` is `chain_stats.funded_txo_sum` — sats ever sent to
// the address. Does not regress when the beneficiary spends.
const addressQuery = useQuery({
queryKey: ['bitcoin-balance', 'campaign', esploraApis, walletValue ?? ''],
queryFn: ({ signal }) => fetchAddressData(walletValue!, esploraApis, signal),
enabled: !!walletValue && isOnchain,
staleTime: 30_000,
refetchInterval: 30_000,
});
// Donor list / breakdown: fetch kind 8333 receipts. Disabled for SP
// campaigns (no receipts are published by design).
const receiptsQuery = useQuery({
queryKey: ['campaign-donations', 'events', aTag ?? ''],
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
@@ -89,10 +114,9 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
return Array.from(byTxid.values());
})();
// Step 2: verify each unique-txid receipt against the campaign's `w`
// wallet address. SP campaigns are short-circuited above so the
// wallet here is always `onchain` mode when present.
const walletValue = wallet?.value;
// Verify each unique-txid receipt against the campaign's `w` wallet
// address. The verified entries drive the donor list / breakdown UI,
// not the headline raised total.
const verifications = useQueries({
queries: dedupedByTxid.map((event) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
@@ -107,7 +131,8 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
.map((v) => v.data)
.filter((v): v is OnchainZapEntry => !!v);
const totalSats = verified.reduce((sum, v) => sum + v.amountSats, 0);
const totalSats = isOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
const txids = new Set<string>();
const donors = new Set<string>();
for (const v of verified) {
@@ -117,7 +142,11 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
const isVerifying = !isSilentPayment && (receiptsQuery.isLoading || verifications.some((v) => v.isLoading));
const isVerifying =
!isSilentPayment &&
(addressQuery.isLoading ||
receiptsQuery.isLoading ||
verifications.some((v) => v.isLoading));
return {
data: {
+3 -1
View File
@@ -184,7 +184,9 @@ export function useDonateCampaign() {
queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] });
}
// Campaign list views (and per-campaign progress bars) read totals via
// useCampaignDonations, so refresh those too.
// useCampaignDonations, which keys its address-balance lookup under
// ['bitcoin-balance', 'campaign', …]. The broader ['bitcoin-balance']
// invalidation above already covers it.
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
// Donations (kind 8333 receipts) surface in the home Agora activity
+23 -60
View File
@@ -1,25 +1,20 @@
import { useNostr } from '@nostrify/react';
import { useQueries } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useAppContext } from '@/hooks/useAppContext';
import {
extractOnchainZapTxid,
verifyOnchainZap,
} from '@/hooks/useOnchainZaps';
import { fetchAddressData } from '@/lib/bitcoin';
import type { ParsedCampaign } from '@/lib/campaign';
export interface ProfileCampaignStats {
/** Total number of non-deleted campaigns authored by this pubkey. */
campaignCount: number;
/**
* Sum of verified on-chain donations across all of this user's
* campaigns, in sats. Silent-payment campaigns contribute 0 by design
* (donations are unlinkable, no receipts are published).
* Sum of cumulative on-chain receipts (`chain_stats.funded_txo_sum`)
* across all of this user's on-chain campaigns, in sats. Silent-payment
* campaigns contribute 0 by design (donations are unlinkable).
*/
totalRaisedSats: number;
/** True while donation verification queries are still resolving. */
/** True while underlying address-balance queries are still resolving. */
isVerifying: boolean;
/** The raw campaigns list, for reuse by the chip click handler. */
campaigns: ParsedCampaign[];
@@ -28,18 +23,17 @@ export interface ProfileCampaignStats {
/**
* Aggregate campaign and donation stats for a single profile.
*
* Mirrors {@link useCampaignDonations} per campaign — fetches kind 8333
* receipts targeting each `a` coord, dedupes by txid, and verifies each
* one on-chain against the campaign's `w` address before counting it
* toward the total. Silent-payment campaigns are excluded from the
* verification fan-out (their donations are intentionally unlinkable).
* Mirrors {@link useCampaignDonations} per campaign — fans out a balance
* lookup against each on-chain campaign's `w` address via the configured
* Esplora endpoint (default: mempool.space) and sums `totalReceived`
* across them. Silent-payment campaigns are excluded (their donations
* are intentionally unlinkable).
*
* Lazy: returns 0 / empty until the campaigns list resolves, then fans
* out receipt fetches in parallel. Suitable for header stat chips where
* out balance fetches in parallel. Suitable for header stat chips where
* an in-flight number is fine.
*/
export function useProfileCampaignStats(pubkey: string | undefined): ProfileCampaignStats {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraApis } = config;
@@ -48,59 +42,28 @@ export function useProfileCampaignStats(pubkey: string | undefined): ProfileCamp
);
const campaigns = pubkey ? (campaignsQuery.data ?? []) : [];
// Fan out: one receipt fetch per on-chain campaign.
// Fan out: one balance lookup per on-chain campaign address.
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
const receiptsQueries = useQueries({
const balanceQueries = useQueries({
queries: onchainCampaigns.map((campaign) => ({
queryKey: ['campaign-donations', 'events', campaign.aTag],
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
return nostr.query(
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
{ signal },
);
},
staleTime: 15_000,
})),
});
// Flatten the receipts and dedupe by txid (prefer earliest, like
// useCampaignDonations does). Track which campaign each txid belongs to
// so we can verify against the right wallet.
const verificationInputs: Array<{ campaign: ParsedCampaign; event: NostrEvent }> = [];
const seenByCampaign = new Map<string, Set<string>>();
for (let i = 0; i < onchainCampaigns.length; i++) {
const campaign = onchainCampaigns[i];
const receipts = receiptsQueries[i]?.data ?? [];
const sortedAsc = [...receipts].sort((a, b) => a.created_at - b.created_at);
const seenTxids = new Set<string>();
for (const event of sortedAsc) {
const txid = extractOnchainZapTxid(event);
if (!txid) continue;
if (seenTxids.has(txid)) continue;
seenTxids.add(txid);
verificationInputs.push({ campaign, event });
}
seenByCampaign.set(campaign.aTag, seenTxids);
}
const verifications = useQueries({
queries: verificationInputs.map(({ campaign, event }) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, campaign.wallet?.value ?? ''],
queryFn: () => verifyOnchainZap(event, esploraApis, campaign.wallet?.value),
staleTime: 60_000,
// Share the cache key with useCampaignDonations so both surfaces
// refresh together when useDonateCampaign invalidates
// ['bitcoin-balance'].
queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchAddressData(campaign.wallet!.value, esploraApis, signal),
staleTime: 30_000,
enabled: !!campaign.wallet?.value,
})),
});
const totalRaisedSats = verifications.reduce(
(sum, v) => sum + (v.data?.amountSats ?? 0),
const totalRaisedSats = balanceQueries.reduce(
(sum, q) => sum + (q.data?.totalReceived ?? 0),
0,
);
const isVerifying =
campaignsQuery.isLoading ||
receiptsQueries.some((q) => q.isLoading) ||
verifications.some((v) => v.isLoading);
campaignsQuery.isLoading || balanceQueries.some((q) => q.isLoading);
return {
campaignCount: campaigns.length,