diff --git a/src/components/RecipientCard.svelte b/src/components/RecipientCard.svelte
index 9f2a256..0476eec 100644
--- a/src/components/RecipientCard.svelte
+++ b/src/components/RecipientCard.svelte
@@ -112,39 +112,66 @@
{:else}
+ {@const t = props.data}
+ {@const sym = t.symbol || 'CC'}
+ {@const fmtCc = (v: number | null) => v == null ? '—' : v.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+ {@const pctOfSupply = t.treasuryCcEquivalent !== null && t.moneySupply !== null && t.moneySupply > 0
+ ? (t.treasuryCcEquivalent / t.moneySupply) * 100 : null}
+ {@const pctOfTurnover = t.treasuryCcEquivalent !== null && t.turnoverLast3Months !== null && t.turnoverLast3Months > 0
+ ? (t.treasuryCcEquivalent / t.turnoverLast3Months) * 100 : null}
+ {@const fmtPct = (p: number | null) =>
+ p == null ? '—' : p >= 100 ? p.toFixed(0) + '%' : p >= 10 ? p.toFixed(1) + '%' : p.toFixed(2) + '%'}
- {props.data.name}
+ {t.name}
{#if isDisabled}
donations disabled
{/if}
- cid {props.data.cid}{#if props.data.location} — {props.data.location}{/if}
+ cid {t.cid}{#if t.location} — {t.location}{/if}
+
+
+
+
{fmtPct(pctOfSupply)}
+
reserves / total issuance of {sym}
+
+
+
{fmtPct(pctOfTurnover)}
+
reserves / 3m turnover
+
+
+
Currently available in the pot:
- {formatBalance(props.data.usdcBalance, 6)} USDC
+ {formatBalance(t.usdcBalance, 6)} USDC
+ {#if t.treasuryCcEquivalent !== null}
+ ≈ {fmtCc(t.treasuryCcEquivalent)} {sym}
+ {/if}
+
+
+ Total community currency issuance: {fmtCc(t.moneySupply)} {sym}
- {#if props.data.regularlyActivePersonsLoading}
+ {#if t.regularlyActivePersonsLoading}
regularly active unique persons
- {:else if props.data.regularlyActivePersons !== null}
- {props.data.regularlyActivePersons} regularly active unique persons
+ {:else if t.regularlyActivePersons !== null}
+ {t.regularlyActivePersons} regularly active unique persons
{:else}
regularly active unique persons: —
{/if}
Turnover (last 3 full months):
- {#if props.data.turnoverLoading}
+ {#if t.turnoverLoading}
- {:else if props.data.turnoverLast3Months !== null}
-
{props.data.turnoverLast3Months.toLocaleString(undefined, { maximumFractionDigits: 0 })} {props.data.symbol || 'CC'}
- {#if props.data.turnoverLast3MonthsUsdc !== null}
-
≈ {props.data.turnoverLast3MonthsUsdc.toLocaleString(undefined, { maximumFractionDigits: 0 })} USDC
+ {:else if t.turnoverLast3Months !== null}
+
{fmtCc(t.turnoverLast3Months)} {sym}
+ {#if t.turnoverLast3MonthsUsdc !== null}
+
≈ {fmtCc(t.turnoverLast3MonthsUsdc)} USDC
{/if}
{:else}
—
@@ -323,4 +350,34 @@
.mono {
font-family: var(--font-mono);
}
+
+ .kpi-row {
+ display: flex;
+ gap: 0.5rem;
+ margin: 0.4rem 0 0.2rem;
+ }
+ .kpi {
+ flex: 1;
+ padding: 0.4rem 0.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ background: var(--color-surface-hover);
+ text-align: center;
+ line-height: 1.15;
+ }
+ .kpi-value {
+ font-family: var(--font-mono);
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--color-accent);
+ }
+ .kpi-label {
+ font-size: 0.7rem;
+ color: var(--color-text-dim);
+ margin-top: 0.1rem;
+ }
+ .kpi.placeholder .kpi-value {
+ color: var(--color-text-dim);
+ opacity: 0.6;
+ }
diff --git a/src/lib/donate.svelte.ts b/src/lib/donate.svelte.ts
index 45cca3f..f5d118a 100644
--- a/src/lib/donate.svelte.ts
+++ b/src/lib/donate.svelte.ts
@@ -52,6 +52,9 @@ export interface DonateParams {
source: ChainId
recipients: DonateRecipient[]
totalAmount: bigint
+ /** Optional per-recipient weights (positive numbers). When provided and not
+ * all-equal/all-zero, the donation is split proportionally; otherwise even. */
+ weights?: number[]
}
let state = $state
({ step: 'idle' })
@@ -69,8 +72,25 @@ export function destinationChain(token: TokenSymbol): ChainId {
return destChainFor(token)
}
-export function splitAmount(total: bigint, n: number): bigint[] {
+export function splitAmount(total: bigint, n: number, weights?: number[]): bigint[] {
if (n <= 0) return []
+ // Weighted path: only kicks in when weights are supplied AND vary.
+ if (weights && weights.length === n) {
+ const finite = weights.map(w => Number.isFinite(w) && w > 0 ? w : 0)
+ const sum = finite.reduce((s, w) => s + w, 0)
+ const allEqual = finite.every(w => w === finite[0])
+ if (sum > 0 && !allEqual) {
+ // Scale to integer to avoid float precision in BigInt math.
+ const SCALE = 1_000_000_000
+ const scaled = finite.map(w => BigInt(Math.round((w / sum) * SCALE)))
+ const scaledSum = scaled.reduce((s, x) => s + x, 0n)
+ const shares = scaled.map(s => (total * s) / scaledSum)
+ const residual = total - shares.reduce((s, x) => s + x, 0n)
+ shares[0] += residual
+ return shares
+ }
+ }
+ // Even split (default): residual goes to first.
const base = total / BigInt(n)
const remainder = total % BigInt(n)
return Array.from({ length: n }, (_, i) => (i === 0 ? base + remainder : base))
@@ -166,9 +186,9 @@ async function buildPerRecipientCalls(
params: DonateParams,
senderAddress: string,
): Promise {
- const { token, source, recipients, totalAmount } = params
+ const { token, source, recipients, totalAmount, weights } = params
const dest = destChainFor(token)
- const amounts = splitAmount(totalAmount, recipients.length)
+ const amounts = splitAmount(totalAmount, recipients.length, weights)
const srcClient = getClient(source)
if (!srcClient) throw new Error(`No client for ${source}`)
diff --git a/src/lib/forex.ts b/src/lib/forex.ts
index 35950de..275a996 100644
--- a/src/lib/forex.ts
+++ b/src/lib/forex.ts
@@ -65,6 +65,15 @@ export async function convertCcToUsd(symbol: string, ccAmount: number): Promise<
return ccAmount / ccPerUsd
}
+/** Inverse of convertCcToUsd: USD amount → CC equivalent for the given symbol. */
+export async function convertUsdToCc(symbol: string, usdAmount: number): Promise {
+ const k = KNOWN_COMMUNITIES[symbol.toUpperCase()]
+ if (!k) return null
+ const usdToFiat = await cachedUsdToFiat(k.fiat)
+ if (usdToFiat == null) return null
+ return usdAmount * k.localFiatRate * usdToFiat
+}
+
// USDC per 1 KSM (live quote via KAH AssetConversion pool). Cached for the
// session; refreshed if the call is retried after a null result.
//
diff --git a/src/lib/recipients.svelte.ts b/src/lib/recipients.svelte.ts
index cbd2ae6..3777b5b 100644
--- a/src/lib/recipients.svelte.ts
+++ b/src/lib/recipients.svelte.ts
@@ -1,7 +1,7 @@
import { AccountId, Binary } from 'polkadot-api'
import { getClient } from './provider.svelte'
import { getTurnoverLastNMonths, getCurrentReputables } from './accountingApi'
-import { convertCcToUsd, ksmToUsdc } from './forex'
+import { convertCcToUsd, convertUsdToCc, ksmToUsdc } from './forex'
const ENCOINTER_PARA_ID = 1001
const KSM_SS58_PREFIX = 2
@@ -54,6 +54,12 @@ export interface Treasury {
/** Approximate USDC value of the 3-month turnover, derived via known-community
* fiat rates + currency-api USD→fiat. `null` if unknown community or forex failed. */
turnoverLast3MonthsUsdc: number | null
+ /** Money supply: total CC issued for this community (raw principal,
+ * pre-demurrage). `null` once on-chain query completes if unavailable. */
+ moneySupply: number | null
+ /** USDC balance expressed as CC equivalent (treasury USDC × CC/USD forex rate).
+ * `null` if unknown community or forex failed. */
+ treasuryCcEquivalent: number | null
}
const TREASURY_FIXTURE: Record = {
@@ -331,6 +337,19 @@ async function loadTreasuries(
})
}
+ let moneySupply: number | null = null
+ try {
+ const issuance = await encApi.query.EncointerBalances.TotalIssuance.getValue(cidObj) as unknown
+ const bits = extractFixedBits(issuance && typeof issuance === 'object' ? (issuance as { principal?: unknown }).principal : null)
+ if (bits != null) {
+ moneySupply = parseFixedI64F64(bits)
+ } else if (issuance) {
+ console.warn(`[recipients] unexpected TotalIssuance shape for ${cidStr}:`, issuance)
+ }
+ } catch (err) {
+ console.warn(`[recipients] money supply query failed for ${cidStr}`, err)
+ }
+
const info = COMMUNITY_INFO[cidStr] ?? {}
result.push({
cid: cidStr,
@@ -347,17 +366,51 @@ async function loadTreasuries(
turnoverLast3Months: null,
turnoverLoading: true,
turnoverLast3MonthsUsdc: null,
+ moneySupply,
+ treasuryCcEquivalent: null,
})
}
return result
}
+/** Parse encointer's BalanceType (i64F64 fixed-point: 64 fractional bits). */
+function parseFixedI64F64(bits: bigint | string | number): number {
+ const n = typeof bits === 'bigint' ? bits : BigInt(typeof bits === 'string' ? bits.replace(/,/g, '') : bits)
+ // Treat as signed 128-bit; for our use case (issuance) the value is always non-negative.
+ const intPart = Number(n >> 64n)
+ const fracPart = Number(n & ((1n << 64n) - 1n)) / 2 ** 64
+ return intPart + fracPart
+}
+
+/** PAPI may render `BalanceType { bits: i128 }` either as `{ bits: ... }`
+ * or flattened to the inner i128 (bigint/string/number). Tolerate both. */
+function extractFixedBits(v: unknown): bigint | string | number | null {
+ if (v == null) return null
+ if (typeof v === 'bigint' || typeof v === 'number') return v
+ if (typeof v === 'string' && /^-?[\d,]+$/.test(v)) return v
+ if (typeof v === 'object') {
+ const b = (v as { bits?: unknown }).bits
+ if (typeof b === 'bigint' || typeof b === 'number') return b
+ if (typeof b === 'string') return b
+ }
+ return null
+}
+
async function fetchFaucetDripUsdcInto(account: string, dripAmount: bigint) {
const usdc = await ksmToUsdc(dripAmount)
const f = faucets.find(x => x.account === account)
if (f) f.dripUsdc = usdc
}
+async function fetchTreasuryCcEquivalentInto(cid: string) {
+ const t = treasuries.find(x => x.cid === cid)
+ if (!t || !t.symbol) return
+ const usdc = Number(t.usdcBalance) / 1e6
+ const cc = await convertUsdToCc(t.symbol, usdc)
+ const after = treasuries.find(x => x.cid === cid)
+ if (after) after.treasuryCcEquivalent = cc
+}
+
async function fetchRegularlyActiveInto(cid: string) {
const n = await getCurrentReputables(cid)
const t = treasuries.find(x => x.cid === cid)
@@ -419,10 +472,11 @@ export async function loadRecipients() {
faucets = f
treasuries = t
loadedOnce = true
- // Fire turnover + reputables + KSM/USDC quote fetches in parallel; mutate entries as they land.
+ // Fire turnover + reputables + CC-equivalent + KSM/USDC quote fetches in parallel.
for (const tr of treasuries) {
void fetchTurnoverInto(tr.cid)
void fetchRegularlyActiveInto(tr.cid)
+ void fetchTreasuryCcEquivalentInto(tr.cid)
}
for (const fc of faucets) {
void fetchFaucetDripUsdcInto(fc.account, fc.dripAmount)
diff --git a/src/views/DonateView.svelte b/src/views/DonateView.svelte
index ffec666..40a3204 100644
--- a/src/views/DonateView.svelte
+++ b/src/views/DonateView.svelte
@@ -39,6 +39,8 @@
// it to undefined prevents re-applying after the user adjusts.
const initialUrl = parseDonateUrlParams()
+ let showWeightingInfo = $state(false)
+
let token = $state(initialUrl.token ?? 'KSM')
let source = $state(
initialUrl.source && (initialUrl.token ? ALLOWED_SOURCES[initialUrl.token].includes(initialUrl.source) : true)
@@ -122,7 +124,12 @@
const recipients = $derived(
token === 'KSM'
? faucets.map(recipientFromFaucet)
- : treasuries.filter(t => !!t.kahAccount).map(recipientFromTreasury),
+ : [...treasuries]
+ .filter(t => !!t.kahAccount)
+ // Order by regularly-active count, descending. Communities whose
+ // count is still loading (null) sort to the bottom.
+ .sort((a, b) => (b.regularlyActivePersons ?? -1) - (a.regularlyActivePersons ?? -1))
+ .map(recipientFromTreasury),
)
const disabledIds = $derived(
@@ -174,6 +181,40 @@
const totalAmount = $derived(parseAmount(amountStr, decimals))
+ // Weighted distribution per community (USDC):
+ // weight = reputables × √(3m turnover USDC) ÷ √(treasury balance USDC)
+ // — reputables = community-size baseline, √turnover = activity bonus (damped),
+ // 1/√treasury = "neediness" factor (well-funded treasuries get less).
+ // Treasury balance is floored at 1 USDC to keep the divisor sane for empty pots.
+ // Recipients without any data fall back to weight 1.
+ const TREASURY_FLOOR_USDC = 1
+ const recipientWeights = $derived.by(() => {
+ return selectedRecipients.map(r => {
+ if (token === 'USDC') {
+ const t = treasuries.find(x => x.kahAccount === r.id)
+ if (t) {
+ const reputables = Math.max(0, t.regularlyActivePersons ?? 0)
+ const turnoverUsd = Math.max(0, t.turnoverLast3MonthsUsdc ?? 0)
+ const treasuryUsd = Math.max(TREASURY_FLOOR_USDC, Number(t.usdcBalance) / 1e6)
+ const w = (reputables * Math.sqrt(turnoverUsd)) / Math.sqrt(treasuryUsd)
+ return Number.isFinite(w) && w > 0 ? w : 1
+ }
+ }
+ return 1
+ })
+ })
+
+ const weightingActive = $derived(
+ recipientWeights.length > 1 &&
+ !recipientWeights.every(w => w === recipientWeights[0]),
+ )
+
+ const perRecipientAmounts = $derived(
+ totalAmount && selectedRecipients.length > 0
+ ? splitAmount(totalAmount, selectedRecipients.length, weightingActive ? recipientWeights : undefined)
+ : null,
+ )
+
// Soft-warn when the donation looks excessive vs. recent community activity.
// - USDC: compare to sum of selected treasuries' last-3-months turnover (in USDC).
// If none of them have turnover data loaded yet, skip the check.
@@ -212,11 +253,6 @@
}
})
- const perRecipient = $derived.by(() => {
- if (!totalAmount || selectionCount() === 0) return null
- const splits = splitAmount(totalAmount, selectionCount())
- return splits[splits.length - 1] // base share (last == base, first has remainder)
- })
// Available balance on the chosen source for the chosen token (already net of ED).
// Returns null while balances haven't been fetched yet for that chain.
@@ -258,7 +294,10 @@
async function handleContinue() {
if (!validSource || !wallet.address || !totalAmount) return
await estimateDonate(
- { token, source: validSource, recipients: selectedRecipients, totalAmount },
+ {
+ token, source: validSource, recipients: selectedRecipients, totalAmount,
+ weights: weightingActive ? recipientWeights : undefined,
+ },
wallet.address,
)
}
@@ -266,7 +305,10 @@
async function handleConfirm() {
if (!validSource || !wallet.address || !wallet.signer || !totalAmount) return
const ok = await executeDonate(
- { token, source: validSource, recipients: selectedRecipients, totalAmount },
+ {
+ token, source: validSource, recipients: selectedRecipients, totalAmount,
+ weights: weightingActive ? recipientWeights : undefined,
+ },
wallet.signer,
wallet.address,
)
@@ -308,6 +350,9 @@
See treasuries.
+
+ Get to know our communities ↗
+
{#if recipientsLoading && !isRecipientsLoaded()}
@@ -345,14 +390,12 @@
type="button"
>
{CHAINS[c].name}
-
- your balance:
- {#if !wallet.connected}
- 0 {token}
- {:else}
+ {#if wallet.connected}
+
+ your balance:
{bal ? formatBalance(bal.transferable, cDecimals) : '—'} {token}
- {/if}
-
+
+ {/if}
{#if c !== dest}
cross-chain
{/if}
@@ -410,12 +453,53 @@
{/if}
- {#if perRecipient !== null && selectionCount() > 1}
-