From acfa811672ab4a5e7ac467b83e673233aedd5512 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 6 May 2026 17:22:04 +0200 Subject: [PATCH 1/2] cosmetics and link to our communities page --- src/views/DonateView.svelte | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/views/DonateView.svelte b/src/views/DonateView.svelte index ffec666..3229106 100644 --- a/src/views/DonateView.svelte +++ b/src/views/DonateView.svelte @@ -122,7 +122,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( @@ -308,6 +313,9 @@ See treasuries. +

+ Get to know our communities ↗ +

{#if recipientsLoading && !isRecipientsLoaded()} @@ -345,14 +353,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} @@ -525,6 +531,11 @@ .intro strong { color: var(--color-text); } + .intro-cta { + text-align: center; + margin-top: 0.6rem !important; + font-weight: 500; + } .intro a { color: var(--color-accent); text-decoration: none; From 0e421896f20bf9371e1f3bdf89f82a8369514c34 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 6 May 2026 18:19:14 +0200 Subject: [PATCH 2/2] weighted shares --- src/components/RecipientCard.svelte | 79 ++++++++++-- src/lib/donate.svelte.ts | 26 +++- src/lib/forex.ts | 9 ++ src/lib/recipients.svelte.ts | 58 ++++++++- src/views/DonateView.svelte | 186 ++++++++++++++++++++++++++-- 5 files changed, 330 insertions(+), 28 deletions(-) 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 3229106..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) @@ -179,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. @@ -217,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. @@ -263,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, ) } @@ -271,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, ) @@ -416,12 +453,53 @@ {/if}
- {#if perRecipient !== null && selectionCount() > 1} -
+ {#if perRecipientAmounts && selectionCount() > 1} +
- - ≈ {formatBalance(perRecipient, decimals)} {token} per recipient - +
+
+ Distribution + {#if weightingActive} + weighted by activity + + {/if} +
+ {#if weightingActive && showWeightingInfo} + + {/if} +
    + {#each selectedRecipients as r, i} +
  • + {r.label} + {formatBalance(perRecipientAmounts[i], decimals)} {token} +
  • + {/each} +
+
{/if} @@ -704,6 +782,90 @@ text-align: center; } + .recipient-breakdown { + align-items: stretch; + } + .breakdown-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; + } + .breakdown-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--color-text-dim); + } + .weighted-tag { + font-size: 0.7rem; + padding: 0.05rem 0.4rem; + border: 1px solid var(--color-border); + border-radius: 999px; + color: var(--color-text-dim); + } + .info-btn { + width: 1.1rem; + height: 1.1rem; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 50%; + background: transparent; + color: var(--color-text-dim); + font-size: 0.7rem; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + .info-btn:hover { + color: var(--color-accent); + border-color: var(--color-accent); + } + .info-popover { + padding: 0.55rem 0.7rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface-hover); + font-size: 0.78rem; + line-height: 1.45; + } + .info-popover strong { display: block; margin-bottom: 0.2rem; } + .info-popover .formula { + font-family: var(--font-mono); + font-size: 0.75rem; + margin: 0.2rem 0 0.35rem; + color: var(--color-text); + } + .info-popover p { + margin: 0.25rem 0; + color: var(--color-text-dim); + } + .breakdown-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.82rem; + } + .breakdown-list li { + display: flex; + justify-content: space-between; + gap: 0.5rem; + } + .breakdown-list .r-name { + color: var(--color-text); + } + .breakdown-list .r-amount { + color: var(--color-text-dim); + } + .mono { font-family: var(--font-mono); } + .summary { display: flex; flex-direction: column;