Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 68 additions & 11 deletions src/components/RecipientCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -112,39 +112,66 @@
</div>
</div>
{: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) + '%'}
<div class="info">
<div class="row">
<span class="title">
{props.data.name}
{t.name}
{#if isDisabled}
<span class="disabled-badge">donations disabled</span>
{/if}
</span>
</div>
<div class="line dim-text">
cid {props.data.cid}{#if props.data.location} — {props.data.location}{/if}
cid {t.cid}{#if t.location} — {t.location}{/if}
</div>

<div class="kpi-row">
<div class="kpi" class:placeholder={pctOfSupply == null}>
<div class="kpi-value">{fmtPct(pctOfSupply)}</div>
<div class="kpi-label">reserves / total issuance of {sym}</div>
</div>
<div class="kpi" class:placeholder={pctOfTurnover == null}>
<div class="kpi-value">{fmtPct(pctOfTurnover)}</div>
<div class="kpi-label">reserves / 3m turnover</div>
</div>
</div>

<div class="line">
Currently available in the pot:
<span class="mono">{formatBalance(props.data.usdcBalance, 6)} USDC</span>
<span class="mono">{formatBalance(t.usdcBalance, 6)} USDC</span>
{#if t.treasuryCcEquivalent !== null}
<span class="dim-text">≈ {fmtCc(t.treasuryCcEquivalent)} {sym}</span>
{/if}
</div>
<div class="line">
Total community currency issuance: <span class="mono">{fmtCc(t.moneySupply)} {sym}</span>
</div>
<div class="line">
{#if props.data.regularlyActivePersonsLoading}
{#if t.regularlyActivePersonsLoading}
<span class="spinner spinner-sm"></span> 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: <span class="dim-text">—</span>
{/if}
</div>
<div class="line">
Turnover (last 3 full months):
{#if props.data.turnoverLoading}
{#if t.turnoverLoading}
<span class="spinner spinner-sm"></span>
{:else if props.data.turnoverLast3Months !== null}
<span class="mono">{props.data.turnoverLast3Months.toLocaleString(undefined, { maximumFractionDigits: 0 })} {props.data.symbol || 'CC'}</span>
{#if props.data.turnoverLast3MonthsUsdc !== null}
<span class="dim-text">≈ {props.data.turnoverLast3MonthsUsdc.toLocaleString(undefined, { maximumFractionDigits: 0 })} USDC</span>
{:else if t.turnoverLast3Months !== null}
<span class="mono">{fmtCc(t.turnoverLast3Months)} {sym}</span>
{#if t.turnoverLast3MonthsUsdc !== null}
<span class="dim-text">≈ {fmtCc(t.turnoverLast3MonthsUsdc)} USDC</span>
{/if}
{:else}
Expand Down Expand Up @@ -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;
}
</style>
26 changes: 23 additions & 3 deletions src/lib/donate.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DonateState>({ step: 'idle' })
Expand All @@ -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))
Expand Down Expand Up @@ -166,9 +186,9 @@ async function buildPerRecipientCalls(
params: DonateParams,
senderAddress: string,
): Promise<UnsignedTx[]> {
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}`)
Expand Down
9 changes: 9 additions & 0 deletions src/lib/forex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null> {
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.
//
Expand Down
58 changes: 56 additions & 2 deletions src/lib/recipients.svelte.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, { name: string; encointerAccount: string; kahAccount: string }> = {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading