diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..3be848e --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_ACCOUNTING_API_URL=https://api.encointer.org/v1 diff --git a/.gitignore b/.gitignore index a547bf3..438657a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/src/App.svelte b/src/App.svelte index e8ca8f8..5ef21d7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,13 +2,24 @@ import Header from './components/Header.svelte' import HomeView from './views/HomeView.svelte' import TransferView from './views/TransferView.svelte' + import DonateView from './views/DonateView.svelte' import type { TransferParams } from './lib/types' import { connect } from './lib/provider.svelte' import { autoReconnect, getWalletState } from './lib/wallet.svelte' import { startAutoRefresh, stopAutoRefresh } from './lib/balances.svelte' import { getProviderMode } from './lib/settings.svelte' - let view = $state<'home' | 'transfer'>('home') + type View = 'home' | 'transfer' | 'donate' + + function viewFromHash(hash: string): View { + // Strip query portion so `#donate?asset=...` still matches `#donate`. + const route = hash.split('?')[0] + if (route === '#donate') return 'donate' + if (route === '#transfer') return 'transfer' + return 'home' + } + + let view = $state(viewFromHash(window.location.hash)) let transferParams = $state(null) // Initialize provider + wallet on mount @@ -40,22 +51,17 @@ } function handleHashChange() { - if (window.location.hash !== '#transfer') { - view = 'home' - transferParams = null - } + const next = viewFromHash(window.location.hash) + view = next + if (next !== 'transfer') transferParams = null } $effect(() => { - // Initialize from hash - if (window.location.hash === '#transfer') { - // If no params, go home - if (!transferParams) { - view = 'home' - window.location.hash = '' - } + // If landed on #transfer with no params (e.g. page reload), redirect home + if (view === 'transfer' && !transferParams) { + view = 'home' + window.location.hash = '' } - window.addEventListener('hashchange', handleHashChange) return () => window.removeEventListener('hashchange', handleHashChange) }) @@ -66,11 +72,20 @@
{#if view === 'transfer' && transferParams} + {:else if view === 'donate'} + {:else} {/if}
+ + diff --git a/src/components/AddressProofModal.svelte b/src/components/AddressProofModal.svelte new file mode 100644 index 0000000..2e37b4d --- /dev/null +++ b/src/components/AddressProofModal.svelte @@ -0,0 +1,171 @@ + + + + + + diff --git a/src/components/Header.svelte b/src/components/Header.svelte index f6e596c..1b17701 100644 --- a/src/components/Header.svelte +++ b/src/components/Header.svelte @@ -7,6 +7,15 @@ let showWallet = $state(false) let showSettings = $state(false) + let currentHash = $state(window.location.hash) + // Route portion only (strip ?query) + const currentRoute = $derived(currentHash.split('?')[0]) + + $effect(() => { + const onHashChange = () => { currentHash = window.location.hash } + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }) const wallet = $derived(getWalletState()) @@ -14,7 +23,11 @@
- Transfer +
@@ -73,9 +86,23 @@ width: auto; } - .title { + .tabs { + display: flex; + gap: 0.25rem; + } + + .tab { + padding: 0.3rem 0.6rem; font-weight: 600; - font-size: 1.1rem; + font-size: 0.95rem; + color: var(--color-text-dim); + text-decoration: none; + border-bottom: 2px solid transparent; + } + + .tab.active { + color: var(--color-text); + border-bottom-color: var(--color-accent); } .settings-btn { diff --git a/src/components/RecipientCard.svelte b/src/components/RecipientCard.svelte new file mode 100644 index 0000000..9f2a256 --- /dev/null +++ b/src/components/RecipientCard.svelte @@ -0,0 +1,326 @@ + + + +
+ + + {#if props.kind === 'faucet'} +
+
+ {props.data.name} +
+
+ Currently available in the pot: + {formatBalance(props.data.freeBalance, 12)} KSM +
+
+ ~{props.data.attestedPersons} unique persons attested eligible to drip + {formatBalance(props.data.dripAmount, 12)} KSM + {#if props.data.dripUsdc === undefined} + + {:else if props.data.dripUsdc !== null} + (≈ {props.data.dripUsdc.toLocaleString(undefined, { maximumFractionDigits: 2 })} USD) + {/if} + every 10 days +
+
+ {#if props.data.whitelist == null} + open to all communities + {:else if props.data.whitelist.length === 0} + no eligible communities + {:else} + eligible communities: {props.data.whitelist.join(', ')} + {/if} +
+
+ {destChainName}: + {truncateAddress(destAccount || '—')} + + +
+
+ {:else} +
+
+ + {props.data.name} + {#if isDisabled} + donations disabled + {/if} + +
+
+ cid {props.data.cid}{#if props.data.location} — {props.data.location}{/if} +
+
+ Currently available in the pot: + {formatBalance(props.data.usdcBalance, 6)} USDC +
+
+ {#if props.data.regularlyActivePersonsLoading} + regularly active unique persons + {:else if props.data.regularlyActivePersons !== null} + {props.data.regularlyActivePersons} regularly active unique persons + {:else} + regularly active unique persons: + {/if} +
+
+ Turnover (last 3 full months): + {#if props.data.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 + {/if} + {:else} + — + {/if} +
+
+ {destChainName}: + {truncateAddress(destAccount || '—')} + + + {#if subscanLink} + e.stopPropagation()} + >Subscan ↗ + {/if} +
+
+ {/if} +
+ +{#if showProof} + {#if props.kind === 'faucet'} + + {:else} + + {/if} +{/if} + + diff --git a/src/lib/accountingApi.ts b/src/lib/accountingApi.ts new file mode 100644 index 0000000..2ab8260 --- /dev/null +++ b/src/lib/accountingApi.ts @@ -0,0 +1,96 @@ +/// + +const API_URL = (import.meta.env.VITE_ACCOUNTING_API_URL as string | undefined) ?? 'https://api.encointer.org/v1' + +interface VolumeReport { + data: Record + communityName: string + year: number +} + +async function getVolumeReport(cid: string, year: number): Promise { + try { + const url = `${API_URL}/accounting/volume-report?cid=${encodeURIComponent(cid)}&year=${year}` + const res = await fetch(url, { credentials: 'omit' }) + if (!res.ok) { + console.warn(`[accounting] volume-report ${cid} ${year} → ${res.status}`) + return null + } + return await res.json() as VolumeReport + } catch (err) { + console.warn(`[accounting] volume-report ${cid} ${year} fetch failed`, err) + return null + } +} + +/** + * Returns the (year, month) pairs for the last `n` fully-completed calendar months + * before `now` (UTC). Most recent first. + */ +export function lastFullMonths(n: number, now: Date = new Date()): Array<{ year: number; month: number }> { + const out: Array<{ year: number; month: number }> = [] + let y = now.getUTCFullYear() + let m = now.getUTCMonth() - 1 // last full month + for (let i = 0; i < n; i++) { + if (m < 0) { m = 11; y -= 1 } + out.push({ year: y, month: m }) + m -= 1 + } + return out +} + +interface ReputablesByCindexResponse { + data: Record + communityName: string +} + +/** + * Returns the most recent (highest cindex) reputables count for a community. + * Backed by /v1/accounting/reputables-by-cindex which computes the union of + * accounts that earned reputation within the current reputation lifetime. + * Returns null on fetch failure or if the community has no reputables. + */ +export async function getCurrentReputables(cid: string): Promise { + try { + const url = `${API_URL}/accounting/reputables-by-cindex?cid=${encodeURIComponent(cid)}` + const res = await fetch(url, { credentials: 'omit' }) + if (!res.ok) { + console.warn(`[accounting] reputables-by-cindex ${cid} → ${res.status}`) + return null + } + const body = await res.json() as ReputablesByCindexResponse + const cindices = Object.keys(body.data ?? {}).map(Number).filter(Number.isFinite) + if (cindices.length === 0) return 0 + const latest = Math.max(...cindices) + const v = body.data[String(latest)] + return typeof v === 'number' ? v : null + } catch (err) { + console.warn(`[accounting] reputables-by-cindex ${cid} fetch failed`, err) + return null + } +} + +/** + * Sum the community-currency volume across the last `n` full calendar months. + * Returns null on any fetch failure (treated as "no data"). + */ +export async function getTurnoverLastNMonths(cid: string, n: number): Promise { + const months = lastFullMonths(n) + const years = [...new Set(months.map(m => m.year))] + const reports = await Promise.all(years.map(y => getVolumeReport(cid, y))) + const reportsByYear = new Map() + years.forEach((y, i) => reportsByYear.set(y, reports[i])) + + let total = 0 + let any = false + for (const { year, month } of months) { + const r = reportsByYear.get(year) + if (!r) continue + const v = r.data[String(month)] + if (typeof v === 'number') { + total += v + any = true + } + } + return any ? total : null +} diff --git a/src/lib/donate.svelte.ts b/src/lib/donate.svelte.ts new file mode 100644 index 0000000..45cca3f --- /dev/null +++ b/src/lib/donate.svelte.ts @@ -0,0 +1,487 @@ +import { Builder } from '@paraspell/sdk' +import { AccountId, Binary } from 'polkadot-api' +import type { PolkadotSigner } from 'polkadot-api' +import type { ChainId, TokenSymbol } from './types' +import { toParaSpell, getCurrency } from './chains' +import { getApiOverrides, getClient } from './provider.svelte' +import type { Faucet, Treasury } from './recipients.svelte' + +const ksmSs58 = AccountId(2) + +export interface SubmittedTx { + txHash: string + chain: ChainId +} + +export type DonateState = + | { step: 'idle' } + | { step: 'estimating' } + | { step: 'ready'; fee: bigint; feeSymbol: string; feeDecimals: number; mode: 'batch' | 'sequential' } + | { step: 'executing'; mode: 'batch' | 'sequential'; current: number; total: number } + | { step: 'success'; submitted: SubmittedTx[] } + | { step: 'error'; message: string } + +const SUBSCAN_HOSTS: Partial> = { + kah: 'https://assethub-kusama.subscan.io', + pah: 'https://assethub-polkadot.subscan.io', +} + +export function subscanUrl(chain: ChainId, txHash: string): string | null { + const host = SUBSCAN_HOSTS[chain] + if (!host) return null + return `${host}/extrinsic/${txHash}` +} + +export function subscanAccountUrl(chain: ChainId, address: string): string | null { + const host = SUBSCAN_HOSTS[chain] + if (!host) return null + return `${host}/account/${address}` +} + +export interface DonateRecipient { + /** Stable identifier (account address) — used for selection */ + id: string + /** Recipient SS58 address on the destination chain */ + address: string + /** Display label */ + label: string +} + +export interface DonateParams { + token: TokenSymbol + source: ChainId + recipients: DonateRecipient[] + totalAmount: bigint +} + +let state = $state({ step: 'idle' }) + +function destChainFor(token: TokenSymbol): ChainId { + return token === 'KSM' ? 'encointer' : 'kah' +} + +export const ALLOWED_SOURCES: Record = { + KSM: ['encointer', 'kah'], + USDC: ['kah', 'pah'], +} + +export function destinationChain(token: TokenSymbol): ChainId { + return destChainFor(token) +} + +export function splitAmount(total: bigint, n: number): bigint[] { + if (n <= 0) return [] + const base = total / BigInt(n) + const remainder = total % BigInt(n) + return Array.from({ length: n }, (_, i) => (i === 0 ? base + remainder : base)) +} + +export function recipientFromFaucet(f: Faucet): DonateRecipient { + return { id: f.account, address: f.account, label: f.name || f.account } +} + +export function recipientFromTreasury(t: Treasury): DonateRecipient { + return { id: t.kahAccount, address: t.kahAccount, label: t.name } +} + +interface SrcApi { + tx: { + Balances?: { transfer_keep_alive: (args: unknown) => UnsignedTx } + ForeignAssets?: { transfer_keep_alive: (args: unknown) => UnsignedTx } + Assets?: { transfer_keep_alive: (args: unknown) => UnsignedTx } + Utility: { batch_all: (args: { calls: unknown[] }) => UnsignedTx } + PolkadotXcm: { transfer_assets_using_type_and_then: (args: unknown) => UnsignedTx } + } +} + +interface UnsignedTx { + decodedCall: unknown + getEstimatedFees: (sender: string) => Promise + signAndSubmit: (signer: PolkadotSigner) => Promise<{ txHash: string } | unknown> +} + +const USDC_LOCATION = { + parents: 2, + interior: { + type: 'X4', + value: [ + { type: 'GlobalConsensus', value: { type: 'Polkadot', value: undefined } }, + { type: 'Parachain', value: 1000 }, + { type: 'PalletInstance', value: 50 }, + { type: 'GeneralIndex', value: 1337n }, + ], + }, +} + +/** USDC's location on KAH (foreign-asset id from KAH's perspective) — same shape as USDC_LOCATION */ +const USDC_KAH_DEST_LOCATION = USDC_LOCATION + +function buildSameChainCall( + srcApi: SrcApi, + source: ChainId, + token: TokenSymbol, + beneficiary: string, + amount: bigint, +): UnsignedTx { + if (token === 'KSM' && source === 'encointer') { + if (!srcApi.tx.Balances) throw new Error('Balances pallet missing on encointer') + return srcApi.tx.Balances.transfer_keep_alive({ + dest: { type: 'Id', value: beneficiary }, + value: amount, + }) + } + if (token === 'USDC' && source === 'kah') { + if (!srcApi.tx.ForeignAssets) throw new Error('ForeignAssets pallet missing on KAH') + return srcApi.tx.ForeignAssets.transfer_keep_alive({ + id: USDC_LOCATION, + target: { type: 'Id', value: beneficiary }, + amount, + }) + } + throw new Error(`Unsupported same-chain donation: ${token} on ${source}`) +} + +async function buildCrossChainCall( + source: ChainId, + dest: ChainId, + token: TokenSymbol, + beneficiary: string, + amount: bigint, + senderAddress: string, +): Promise { + const overrides = getApiOverrides() + if (!overrides) throw new Error('Not connected to chains') + const currency = { ...getCurrency(source, token), amount: amount.toString() } + const tx = await Builder({ apiOverrides: overrides }) + .from(toParaSpell(source)) + .to(toParaSpell(dest)) + .currency(currency) + .address(beneficiary) + .senderAddress(senderAddress) + .build() + return tx as unknown as UnsignedTx +} + +async function buildPerRecipientCalls( + params: DonateParams, + senderAddress: string, +): Promise { + const { token, source, recipients, totalAmount } = params + const dest = destChainFor(token) + const amounts = splitAmount(totalAmount, recipients.length) + + const srcClient = getClient(source) + if (!srcClient) throw new Error(`No client for ${source}`) + const srcApi = srcClient.getUnsafeApi() as unknown as SrcApi + + // For bridged routes (currently USDC PAH→KAH), consolidate N recipients into a + // single XCM message with multi-DepositAsset on the destination side. Avoids + // paying the bridging fee N times. + if (source !== dest && recipients.length > 1) { + console.log(`[donate] attempting single-XCM consolidation for ${token} ${source}→${dest} (${recipients.length} recipients)`) + const consolidated = await tryBuildConsolidatedXcm( + source, dest, token, + recipients.map((r, i) => ({ address: r.address, amount: amounts[i] })), + totalAmount, + senderAddress, + ) + if (consolidated) { + console.log('[donate] consolidation produced single tx ✓') + return [consolidated] + } + console.log('[donate] consolidation returned null; using per-recipient batch') + } + + const calls: UnsignedTx[] = [] + for (let i = 0; i < recipients.length; i++) { + const r = recipients[i] + const amt = amounts[i] + if (source === dest) { + calls.push(buildSameChainCall(srcApi, source, token, r.address, amt)) + } else { + calls.push(await buildCrossChainCall(source, dest, token, r.address, amt, senderAddress)) + } + } + return calls +} + +/** + * Attempts to build a single XCM that delivers funds to all recipients in one + * cross-chain message via `polkadotXcm.transfer_assets_using_type_and_then`. + * + * Strategy: let paraspell build a tx for a single recipient with the *total* + * amount (so it picks the right TransferType, dest location, BuyExecution, fee + * handling). If the resulting call is `transfer_assets_using_type_and_then`, + * patch its `custom_xcm_on_dest` to replace the single DepositAsset with N + * DepositAssets — first N-1 with `Definite{ id, Fungible(amount) }`, last with + * `Wild(AllCounted: 1)` to sweep the bridge-fee remainder. + * + * Returns `null` if the route isn't a `transfer_assets_using_type_and_then` one + * (e.g. KAH→Encointer KSM uses `limited_teleport_assets`); caller falls back to + * per-recipient calls + Utility.batch_all. + */ +async function tryBuildConsolidatedXcm( + source: ChainId, + dest: ChainId, + token: TokenSymbol, + recipients: Array<{ address: string; amount: bigint }>, + totalAmount: bigint, + senderAddress: string, +): Promise { + // Currently only USDC PAH→KAH has a bridge with non-trivial fees per message. + // KAH→Encointer KSM is sibling-teleport (cheap) and uses a different call shape. + if (!(token === 'USDC' && source === 'pah' && dest === 'kah')) return null + + const overrides = getApiOverrides() + if (!overrides) return null + const srcClient = getClient(source) + if (!srcClient) return null + const srcApi = srcClient.getUnsafeApi() as unknown as SrcApi + + // Build paraspell tx for the first recipient with the TOTAL amount. + const currency = { ...getCurrency(source, token), amount: totalAmount.toString() } + const psTx = await Builder({ apiOverrides: overrides }) + .from(toParaSpell(source)) + .to(toParaSpell(dest)) + .currency(currency) + .address(recipients[0].address) + .senderAddress(senderAddress) + .build() + + // Inspect & patch the decoded call. + type DecodedCall = { type?: string; value?: { type?: string; value?: Record } } & Record + const decoded = (psTx as unknown as { decodedCall: DecodedCall }).decodedCall + console.log('[donate] paraspell decodedCall:', JSON.stringify(decoded, (_k, v) => typeof v === 'bigint' ? v.toString() + 'n' : v, 2)) + + // Detect call shape: PAPI normalized form or polkadot.js variant-key form. + let callArgs: Record | null = null + if (decoded?.type === 'PolkadotXcm' && decoded.value?.type === 'transfer_assets_using_type_and_then') { + callArgs = decoded.value.value as Record + } else if (typeof decoded === 'object' && decoded !== null) { + // polkadot.js style: { PolkadotXcm: { transfer_assets_using_type_and_then: {...} } } + const pxcm = (decoded as Record).PolkadotXcm as Record | undefined + if (pxcm?.transfer_assets_using_type_and_then) { + callArgs = pxcm.transfer_assets_using_type_and_then as Record + } + } + if (!callArgs) { + console.warn(`[donate] paraspell built unexpected call shape for ${source}->${dest}; falling back to per-recipient batch`) + return null + } + + const xcm = callArgs.custom_xcm_on_dest as { type?: string; value?: unknown[] } & Record + // Detect customXcmOnDest shape: { type: 'V5', value: [...] } OR { V5: [...] } + let instructions: Array> | null = null + let xcmStyle: 'papi' | 'pjs' = 'papi' + let xcmVersionTag: string = 'V5' + if (Array.isArray(xcm.value)) { + instructions = xcm.value as Array> + xcmStyle = 'papi' + xcmVersionTag = (xcm.type as string) ?? 'V5' + } else { + for (const key of Object.keys(xcm)) { + const v = (xcm as Record)[key] + if (Array.isArray(v)) { + instructions = v as Array> + xcmStyle = 'pjs' + xcmVersionTag = key + break + } + } + } + if (!instructions) { + console.warn('[donate] could not locate XCM instructions array; falling back', xcm) + return null + } + console.log(`[donate] xcm style=${xcmStyle} version=${xcmVersionTag}; sample instruction:`, instructions[0]) + + // Find the DepositAsset (in either style) + const isDeposit = (ins: Record) => + ins.type === 'DepositAsset' || 'DepositAsset' in ins + const depIdx = instructions.findIndex(isDeposit) + if (depIdx < 0) { + console.warn('[donate] no DepositAsset in custom_xcm_on_dest; falling back', instructions) + return null + } + + // Build per-recipient deposits in the SAME style as paraspell used. + const mkInstr = (tag: string, value: unknown) => + xcmStyle === 'papi' ? { type: tag, value } : { [tag]: value } + const mkEnum = (tag: string, value: unknown) => + xcmStyle === 'papi' ? { type: tag, value } : { [tag]: value } + + // Note: PAPI flattens `Junctions::X1([Junction; 1])` to a single Junction + // (not an array of length 1). X2..X8 do use arrays. + const beneficiaryFor = (addr: string) => ({ + parents: 0, + interior: xcmStyle === 'papi' + ? { type: 'X1', value: { type: 'AccountId32', value: { network: undefined, id: Binary.fromBytes(ksmSs58.enc(addr)) } } } + : { X1: { AccountId32: { network: null, id: Binary.fromBytes(ksmSs58.enc(addr)) } } }, + }) + + const newDeposits = recipients.map((r, i) => { + const isLast = i === recipients.length - 1 + const beneficiary = beneficiaryFor(r.address) + const assets = isLast + ? mkEnum('Wild', mkEnum('AllCounted', 1)) + : mkEnum('Definite', [{ id: USDC_KAH_DEST_LOCATION, fun: mkEnum('Fungible', r.amount) }]) + return mkInstr('DepositAsset', { assets, beneficiary }) + }) + + instructions.splice(depIdx, 1, ...newDeposits) + console.log('[donate] patched custom_xcm_on_dest:', JSON.stringify(instructions, (_k, v) => typeof v === 'bigint' ? v.toString() + 'n' : v, 2)) + + try { + return srcApi.tx.PolkadotXcm.transfer_assets_using_type_and_then(callArgs) + } catch (err) { + console.warn('[donate] failed to re-encode patched call; falling back', err) + return null + } +} + +async function buildBatch(source: ChainId, calls: UnsignedTx[]): Promise { + if (calls.length <= 1) return calls[0] ?? null + const srcClient = getClient(source) + if (!srcClient) return null + const srcApi = srcClient.getUnsafeApi() as unknown as SrcApi + try { + const decoded = calls.map(c => c.decodedCall) + return srcApi.tx.Utility.batch_all({ calls: decoded }) + } catch (err) { + console.warn('[donate] batch_all build failed; will fall back to sequential', err) + return null + } +} + +export async function estimateDonate( + params: DonateParams, + senderAddress: string, +): Promise { + state = { step: 'estimating' } + try { + const calls = await buildPerRecipientCalls(params, senderAddress) + if (calls.length === 0) { + state = { step: 'error', message: 'No recipients selected' } + return + } + const batch = await buildBatch(params.source, calls) + if (batch) { + try { + const fee = await batch.getEstimatedFees(senderAddress) + const feeMeta = sourceFeeAsset(params.source) + state = { step: 'ready', fee, feeSymbol: feeMeta.symbol, feeDecimals: feeMeta.decimals, mode: 'batch' } + return + } catch (err) { + console.warn('[donate] batch fee estimate failed; trying sequential', err) + } + } + let totalFee = 0n + for (const call of calls) { + try { totalFee += await call.getEstimatedFees(senderAddress) } catch { /* skip */ } + } + const feeMeta = sourceFeeAsset(params.source) + state = { step: 'ready', fee: totalFee, feeSymbol: feeMeta.symbol, feeDecimals: feeMeta.decimals, mode: 'sequential' } + } catch (err) { + state = { step: 'error', message: err instanceof Error ? err.message : 'Estimation failed' } + } +} + +function sourceFeeAsset(source: ChainId): { symbol: string; decimals: number } { + // Native fee token of the source chain + if (source === 'pah') return { symbol: 'DOT', decimals: 10 } + return { symbol: 'KSM', decimals: 12 } +} + +function isUserCancel(err: unknown): boolean { + const msg = (err instanceof Error ? err.message : String(err)).toLowerCase() + return msg.includes('cancel') || msg.includes('reject') || msg.includes('user denied') +} + +function extractTxHash(res: unknown): string | null { + if (typeof res === 'string' && res.startsWith('0x')) return res + if (typeof res === 'object' && res !== null) { + const r = res as { txHash?: unknown } + if (typeof r.txHash === 'string') return r.txHash + } + return null +} + +export async function executeDonate( + params: DonateParams, + signer: PolkadotSigner, + senderAddress: string, +): Promise { + try { + const calls = await buildPerRecipientCalls(params, senderAddress) + if (calls.length === 0) { + state = { step: 'error', message: 'No recipients selected' } + return false + } + + const submitted: SubmittedTx[] = [] + + if (calls.length > 1) { + const batch = await buildBatch(params.source, calls) + if (batch) { + state = { step: 'executing', mode: 'batch', current: 0, total: 1 } + try { + const res = await batch.signAndSubmit(signer) + const txHash = extractTxHash(res) + if (txHash) submitted.push({ txHash, chain: params.source }) + state = { step: 'success', submitted } + return true + } catch (err) { + if (isUserCancel(err)) { + state = { step: 'error', message: 'Cancelled' } + return false + } + console.warn('[donate] batch submit failed; falling back to sequential', err) + } + } + } else { + // Single call (e.g. consolidated XCM, or single recipient). + state = { step: 'executing', mode: 'batch', current: 0, total: 1 } + try { + const res = await calls[0].signAndSubmit(signer) + const txHash = extractTxHash(res) + if (txHash) submitted.push({ txHash, chain: params.source }) + state = { step: 'success', submitted } + return true + } catch (err) { + if (isUserCancel(err)) { + state = { step: 'error', message: 'Cancelled' } + return false + } + const msg = err instanceof Error ? err.message : 'Submission failed' + state = { step: 'error', message: msg } + return false + } + } + + for (let i = 0; i < calls.length; i++) { + state = { step: 'executing', mode: 'sequential', current: i, total: calls.length } + try { + const res = await calls[i].signAndSubmit(signer) + const txHash = extractTxHash(res) + if (txHash) submitted.push({ txHash, chain: params.source }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Submission failed' + state = { step: 'error', message: `Recipient ${i + 1}/${calls.length}: ${msg}` } + return false + } + } + state = { step: 'success', submitted } + return true + } catch (err) { + state = { step: 'error', message: err instanceof Error ? err.message : 'Execution failed' } + return false + } +} + +export function resetDonate() { + state = { step: 'idle' } +} + +export function getDonateState(): DonateState { + return state +} diff --git a/src/lib/donateUrl.ts b/src/lib/donateUrl.ts new file mode 100644 index 0000000..bb85571 --- /dev/null +++ b/src/lib/donateUrl.ts @@ -0,0 +1,62 @@ +import type { ChainId, TokenSymbol } from './types' +import { ALLOWED_SOURCES } from './donate.svelte' + +export interface DonateUrlParams { + token?: TokenSymbol + source?: ChainId + amount?: string + /** + * Recipient identifiers. Interpretation depends on token: + * - USDC: list of cids (community identifiers, base58 like "u0qj944rhWE") + * - KSM: list of faucet account SS58 addresses OR faucet names (matched case-insensitively) + */ + recipients?: string[] +} + +/** + * Parse `?asset=...&source=...&amount=...&recipients=cid1,cid2` from the URL hash. + * The hash may carry a route prefix (e.g. `#donate?asset=USDC...`); we read + * everything after the first `?`. + */ +export function parseDonateUrlParams(hash: string = window.location.hash): DonateUrlParams { + const qIdx = hash.indexOf('?') + if (qIdx < 0) return {} + const sp = new URLSearchParams(hash.slice(qIdx + 1)) + const out: DonateUrlParams = {} + + const asset = sp.get('asset')?.toUpperCase() + if (asset === 'KSM' || asset === 'USDC') out.token = asset + + const source = sp.get('source')?.toLowerCase() + const validChain = source === 'encointer' || source === 'kah' || source === 'pah' + if (validChain) { + if (out.token) { + // Asset known: only accept source if compatible. + if (ALLOWED_SOURCES[out.token].includes(source as ChainId)) out.source = source as ChainId + } else { + // Asset unknown: accept the source and try to infer the asset from it + // (pah → USDC; encointer → KSM; kah is ambiguous, leave asset to default). + out.source = source as ChainId + const candidates = (['KSM', 'USDC'] as TokenSymbol[]) + .filter(t => ALLOWED_SOURCES[t].includes(source as ChainId)) + if (candidates.length === 1) out.token = candidates[0] + } + } + + const amount = sp.get('amount')?.trim() + if (amount && /^\d+\.?\d*$/.test(amount)) out.amount = amount + + const recipients = sp.get('recipients') + if (recipients) { + const parts = recipients.split(',').map(s => s.trim()).filter(Boolean) + if (parts.length > 0) out.recipients = parts + } + + return out +} + +/** Strip the hash route prefix (e.g. "#donate") to get just the route name. */ +export function routeFromHash(hash: string = window.location.hash): string { + const qIdx = hash.indexOf('?') + return qIdx < 0 ? hash : hash.slice(0, qIdx) +} diff --git a/src/lib/forex.ts b/src/lib/forex.ts new file mode 100644 index 0000000..35950de --- /dev/null +++ b/src/lib/forex.ts @@ -0,0 +1,130 @@ +/// + +import { getClient } from './provider.svelte' + +// Hard-coded CC→local-fiat rates for known communities. +// Mirrors encointer-wallet-flutter app/lib/service/forex/known_community.dart +// (markup not applied — we want approximate turnover, not buy/sell quotes). +// +// localFiatRate = CC per 1 unit of local fiat. +// e.g. NYT { fiat: 'tzs', localFiatRate: 0.001 } means 0.001 NYT per TZS, +// i.e. 1 NYT = 1000 TZS. +type FiatCode = 'chf' | 'tzs' | 'ngn' | 'eur' + +const KNOWN_COMMUNITIES: Record = { + LEU: { fiat: 'chf', localFiatRate: 1 }, + NYT: { fiat: 'tzs', localFiatRate: 0.001 }, + PNQ: { fiat: 'ngn', localFiatRate: 0.001 }, + MTA: { fiat: 'eur', localFiatRate: 2 }, +} + +// Same currency-api endpoints the flutter wallet uses. +const FOREX_PRIMARY = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies' +const FOREX_FALLBACK = 'https://cdn.statically.io/gh/fawazahmed0/exchange-api/latest/v1/currencies' + +// Cache USD→fiat rates per session. The flutter wallet caches for 1 day on +// disk; for the dapp's short-lived sessions, in-memory is enough. +const usdToFiatCache = new Map>() + +async function fetchUsdToFiat(target: FiatCode): Promise { + for (const base of [FOREX_PRIMARY, FOREX_FALLBACK]) { + try { + const res = await fetch(`${base}/usd.json`) + if (!res.ok) continue + const data = await res.json() as { usd?: Record } + const rate = data.usd?.[target] + if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) return rate + } catch (err) { + console.warn(`[forex] usd.json fetch failed at ${base}`, err) + } + } + return null +} + +function cachedUsdToFiat(target: FiatCode): Promise { + let p = usdToFiatCache.get(target) + if (!p) { + p = fetchUsdToFiat(target) + usdToFiatCache.set(target, p) + } + return p +} + +/** + * Approximate USD value of `ccAmount` in community currency `symbol`. + * Returns `null` for unknown communities or if the forex fetch fails. + */ +export async function convertCcToUsd(symbol: string, ccAmount: number): Promise { + const k = KNOWN_COMMUNITIES[symbol.toUpperCase()] + if (!k) return null + const usdToFiat = await cachedUsdToFiat(k.fiat) + if (usdToFiat == null) return null + // ccPerUsd = (CC / fiat) * (fiat / USD) + const ccPerUsd = k.localFiatRate * usdToFiat + if (ccPerUsd <= 0) return null + return ccAmount / ccPerUsd +} + +// USDC per 1 KSM (live quote via KAH AssetConversion pool). Cached for the +// session; refreshed if the call is retried after a null result. +// +// Locations are in PAPI-normalized {type, value} form (matches the shape +// recipients.svelte.ts already uses successfully for ForeignAssets queries). +const KSM_LOCATION = { + parents: 1, + interior: { type: 'Here', value: undefined }, +} +const USDC_KAH_LOCATION = { + parents: 2, + interior: { + type: 'X4', + value: [ + { type: 'GlobalConsensus', value: { type: 'Polkadot', value: undefined } }, + { type: 'Parachain', value: 1000 }, + { type: 'PalletInstance', value: 50 }, + { type: 'GeneralIndex', value: 1337n }, + ], + }, +} +let ksmUsdcRatePromise: Promise | null = null + +async function fetchKsmUsdcRate(): Promise { + const kahClient = getClient('kah') + if (!kahClient) return null + try { + const api = kahClient.getUnsafeApi() as unknown as { + apis: { AssetConversionApi: { quote_price_exact_tokens_for_tokens: (a: unknown, b: unknown, amt: bigint, includeFee: boolean) => Promise } } + } + const oneKsm = 10n ** 12n + const result = await api.apis.AssetConversionApi.quote_price_exact_tokens_for_tokens( + KSM_LOCATION, USDC_KAH_LOCATION, oneKsm, true, + ) + if (result == null) return null + // USDC has 6 decimals. + return Number(BigInt(result)) / 1e6 + } catch (err) { + console.warn('[forex] KSM/USDC quote failed', err) + return null + } +} + +export function getKsmUsdcRate(): Promise { + if (!ksmUsdcRatePromise) { + ksmUsdcRatePromise = fetchKsmUsdcRate().then(r => { + // If the fetch fails, allow a future retry. + if (r == null) ksmUsdcRatePromise = null + return r + }) + } + return ksmUsdcRatePromise +} + +/** + * Approximate USDC value of a KSM amount (12 decimals). Returns null if the + * AssetConversion pool isn't reachable. + */ +export async function ksmToUsdc(ksmAmount: bigint): Promise { + const rate = await getKsmUsdcRate() + if (rate == null) return null + return (Number(ksmAmount) / 1e12) * rate +} diff --git a/src/lib/keylessProof.ts b/src/lib/keylessProof.ts new file mode 100644 index 0000000..a8ee467 --- /dev/null +++ b/src/lib/keylessProof.ts @@ -0,0 +1,234 @@ +import { AccountId } from 'polkadot-api' +import { blake2b256 } from '@paraspell/sdk' + +/** + * Reproduce the on-chain account derivations for technical (key-less) accounts: + * - Encointer faucet account + * - Encointer community treasury account + * - Asset Hub Kusama sibling sub-account of an encointer treasury (the "kahAccount") + * + * These derivations are deterministic from public inputs (community identifier, + * faucet name, parachain id). No private key exists. This module both provides + * inline JS implementations the dapp can run for verification, AND emits the + * source of an equivalent self-contained snippet that the user can run in any + * browser DevTools console (depending only on the @noble/hashes CDN). + */ + +const KSM_PREFIX = 2 // Kusama / KAH / encointer-kusama +const ENCOINTER_PARA_ID = 1001 +const TREASURY_PALLET_ID = 'trsrysId' +const FAUCET_PALLET_ID = 'ectrfct0' + +const ksmSs58 = AccountId(KSM_PREFIX) + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function decodeBase58(s: string): Uint8Array { + let n = 0n + for (const c of s) { + const i = BASE58_ALPHABET.indexOf(c) + if (i < 0) throw new Error(`invalid base58 char: ${c}`) + n = n * 58n + BigInt(i) + } + const bytes: number[] = [] + while (n > 0n) { + bytes.unshift(Number(n & 0xffn)) + n >>= 8n + } + let zeros = 0 + for (const c of s) { if (c === '1') zeros++; else break } + return new Uint8Array([...new Array(zeros).fill(0), ...bytes]) +} + +function parseCid(cidStr: string): { geohash: Uint8Array; digest: Uint8Array } { + if (cidStr.length < 6) throw new Error(`bad cid: ${cidStr}`) + const geohash = new TextEncoder().encode(cidStr.slice(0, 5)) + const digest = decodeBase58(cidStr.slice(5)) + if (digest.length !== 4) throw new Error(`expected 4-byte digest, got ${digest.length}`) + return { geohash, digest } +} + +function compactScale(n: number): Uint8Array { + if (n < 64) return new Uint8Array([n << 2]) + if (n < 16384) return new Uint8Array([(n << 2) | 0b01, n >> 6]) + if (n < 1073741824) return new Uint8Array([(n << 2) | 0b10, n >> 6, n >> 14, n >> 22]) + throw new Error('compact: number too large for this helper') +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((s, p) => s + p.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const p of parts) { out.set(p, off); off += p.length } + return out +} + +// ────────────────────────────────────────────────────────────────────────────── +// Derivations (used by the dapp's local "verify" button) + +export function deriveEncointerTreasury(cidStr: string): { ss58: string; bytes: Uint8Array } { + const palletId = new TextEncoder().encode(TREASURY_PALLET_ID) // 8 bytes + const { geohash, digest } = parseCid(cidStr) + // The Rust pallet builds a Vec identifier and then hashes it with + // T::Hashing::hash_of(&id), which goes through Encode::using_encoded — so + // the bytes hashed are the SCALE encoding of the Vec: compact(len) ++ bytes. + const optSome = new Uint8Array([0x01, ...geohash, ...digest]) // SCALE Option::Some(cid) — 10 bytes + const ident = concat(palletId, optSome) // 18 bytes + const preimage = concat(compactScale(ident.length), ident) + const hash = blake2b256(preimage) + return { ss58: ksmSs58.dec(hash), bytes: hash } +} + +export function deriveFaucetAccount(name: string): { ss58: string; bytes: Uint8Array } { + const palletId = new TextEncoder().encode(FAUCET_PALLET_ID) // 8 bytes + const nameBytes = new TextEncoder().encode(name) // raw UTF-8 + // Same Encode-then-hash pattern as the treasury derivation (see comment above). + const ident = concat(palletId, nameBytes) + const preimage = concat(compactScale(ident.length), ident) + const hash = blake2b256(preimage) + return { ss58: ksmSs58.dec(hash), bytes: hash } +} + +export function deriveKahFromTreasury(treasuryBytes: Uint8Array, paraId: number = ENCOINTER_PARA_ID): { ss58: string; bytes: Uint8Array } { + // xcm-builder HashedDescription> for + // Location { parents: 1, interior: X2[Parachain(paraId), AccountId32{network: None, id}] }: + // + // Outer pre-image = SCALE-encode: ([12 bytes "SiblingChain"], Compact(paraId), inner: Vec) + // where inner is itself SCALE-encoded: ([11 bytes "AccountId32"], id: [u8; 32]) + // Note: byte-string literals like b"SiblingChain" encode as fixed-size [u8; N] (no length prefix). + // Vec values inside a tuple ARE compact-prefixed. + const accIdTag = new TextEncoder().encode('AccountId32') // 11 bytes + const inner = concat(accIdTag, treasuryBytes) // 11 + 32 = 43 bytes + const sibling = new TextEncoder().encode('SiblingChain') // 12 bytes + const preimage = concat( + sibling, + compactScale(paraId), + compactScale(inner.length), + inner, + ) + const hash = blake2b256(preimage) + return { ss58: ksmSs58.dec(hash), bytes: hash } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Self-contained snippets the user can paste into any browser console. + +const SNIPPET_PRELUDE = `// Auditable proof: this address has no private key. +// It is deterministically derived from public inputs. +// Run in any browser DevTools (uses only @noble/hashes from the CDN). +(async () => { + const { blake2b } = await import('https://esm.sh/@noble/hashes/blake2b'); + const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const b58dec = s => { + let n = 0n; for (const c of s) n = n * 58n + BigInt(ALPHA.indexOf(c)); + const out = []; while (n > 0n) { out.unshift(Number(n & 0xffn)); n >>= 8n; } + let z = 0; for (const c of s) { if (c === '1') z++; else break; } + return new Uint8Array([...Array(z).fill(0), ...out]); + }; + const b58enc = b => { + let n = 0n; for (const x of b) n = n * 256n + BigInt(x); + let s = ''; while (n > 0n) { s = ALPHA[Number(n % 58n)] + s; n /= 58n; } + let z = 0; for (const x of b) { if (x === 0) z++; else break; } + return '1'.repeat(z) + s; + }; + const ss58 = (bytes, prefix) => { + const pfx = prefix < 64 + ? new Uint8Array([prefix]) + : new Uint8Array([((prefix & 0xfc) >> 2) | 0x40, (prefix >> 8) | ((prefix & 0x03) << 6)]); + const data = new Uint8Array([...pfx, ...bytes]); + const ctx = new TextEncoder().encode('SS58PRE'); + const h = blake2b.create({ dkLen: 64 }).update(ctx).update(data).digest(); + return b58enc(new Uint8Array([...pfx, ...bytes, h[0], h[1]])); + }; + const compact = n => { + if (n < 64) return new Uint8Array([n << 2]); + if (n < 16384) return new Uint8Array([(n << 2) | 1, n >> 6]); + return new Uint8Array([(n << 2) | 2, n >> 6, n >> 14, n >> 22]); + }; + const cat = (...xs) => { + const t = xs.reduce((s, x) => s + x.length, 0); + const o = new Uint8Array(t); let p = 0; + for (const x of xs) { o.set(x, p); p += x.length; } + return o; + };` + +const SNIPPET_EPILOGUE = `})();` + +export function snippetForFaucet(name: string): string { + return `${SNIPPET_PRELUDE} + + // ── Encointer faucet account derivation ───────────────────────────── + // pallet-encointer-faucet :: create_faucet builds a Vec identifier + // ( PalletId(8B "ectrfct0") || raw_utf8(name) ), then hashes it via + // T::Hashing::hash_of(&id) which SCALE-encodes the Vec first: + // account = blake2_256( compact(id.len) || id ) + const palletId = new TextEncoder().encode('${FAUCET_PALLET_ID}'); // 8 bytes + const nameBytes = new TextEncoder().encode(${JSON.stringify(name)}); // raw UTF-8 + const ident = cat(palletId, nameBytes); + const preimage = cat(compact(ident.length), ident); + const hash = blake2b(preimage, { dkLen: 32 }); + const addr = ss58(hash, ${KSM_PREFIX}); + console.log('faucet account:', addr); +${SNIPPET_EPILOGUE}` +} + +export function snippetForEncointerTreasury(cidStr: string): string { + const { geohash } = parseCid(cidStr) + const geohashStr = new TextDecoder().decode(geohash) + const digestB58 = cidStr.slice(5) + return `${SNIPPET_PRELUDE} + + // ── Encointer community treasury account derivation ───────────────── + // pallet-encointer-treasuries :: get_community_treasury_account_unchecked(Some(cid)) + // builds a Vec identifier and hashes via T::Hashing::hash_of(&id) which + // SCALE-encodes the Vec first: + // ident = PalletId(8B "trsrysId") || SCALE(Option::Some(cid)) + // account = blake2_256( compact(ident.len) || ident ) + // (SCALE Option::Some prefix = 0x01; CommunityIdentifier = geohash[5] || digest[4]) + const palletId = new TextEncoder().encode('${TREASURY_PALLET_ID}'); // 8 bytes + const geohash = new TextEncoder().encode(${JSON.stringify(geohashStr)}); // 5 bytes + const digestB58 = ${JSON.stringify(digestB58)}; + const digest = b58dec(digestB58); // 4 bytes + const someCid = new Uint8Array([0x01, ...geohash, ...digest]); // 10 bytes + const ident = cat(palletId, someCid); // 18 bytes + const preimage = cat(compact(ident.length), ident); + const hash = blake2b(preimage, { dkLen: 32 }); + const addr = ss58(hash, ${KSM_PREFIX}); + console.log('encointer treasury account:', addr); +${SNIPPET_EPILOGUE}` +} + +export function snippetForKahTreasury(cidStr: string, paraId: number = ENCOINTER_PARA_ID): string { + const { geohash } = parseCid(cidStr) + const geohashStr = new TextDecoder().decode(geohash) + const digestB58 = cidStr.slice(5) + return `${SNIPPET_PRELUDE} + + // ── Asset Hub Kusama sibling sub-account of an encointer treasury ──── + // Step 1 — derive the encointer treasury account. + // pallet-encointer-treasuries hashes via T::Hashing::hash_of(&id) (Encode-then-hash): + // ident1 = PalletId(8B "trsrysId") || SCALE(Option::Some(cid)) + // treasury = blake2_256( compact(ident1.len) || ident1 ) + const palletId = new TextEncoder().encode('${TREASURY_PALLET_ID}'); + const geohash = new TextEncoder().encode(${JSON.stringify(geohashStr)}); + const digestB58 = ${JSON.stringify(digestB58)}; + const digest = b58dec(digestB58); + const someCid = new Uint8Array([0x01, ...geohash, ...digest]); + const ident1 = cat(palletId, someCid); + const treasury = blake2b(cat(compact(ident1.length), ident1), { dkLen: 32 }); + console.log('encointer treasury account:', ss58(treasury, ${KSM_PREFIX})); + + // Step 2 — derive its sibling sub-account on Asset Hub Kusama. + // xcm-builder: HashedDescription> + // for Location { parents: 1, interior: X2[Parachain(${paraId}), AccountId32{None, treasury}] } + // inner = "AccountId32"(11B raw) || treasury(32B) + // pre2 = "SiblingChain"(12B raw) || Compact(${paraId}) || Compact(inner.len) || inner + // kahAccount = blake2_256(pre2) + const sibling = new TextEncoder().encode('SiblingChain'); // 12 bytes + const accIdTag = new TextEncoder().encode('AccountId32'); // 11 bytes + const inner = cat(accIdTag, treasury); // 11 + 32 = 43 bytes + const pre2 = cat(sibling, compact(${paraId}), compact(inner.length), inner); + const kah = blake2b(pre2, { dkLen: 32 }); + console.log('asset hub kusama sub-account:', ss58(kah, ${KSM_PREFIX})); +${SNIPPET_EPILOGUE}` +} diff --git a/src/lib/provider.svelte.ts b/src/lib/provider.svelte.ts index 240116d..a2aa575 100644 --- a/src/lib/provider.svelte.ts +++ b/src/lib/provider.svelte.ts @@ -33,7 +33,6 @@ function setSyncStatus(chain: ChainId, status: SyncStatus) { } async function createSmoldotClients(): Promise { - // @ts-expect-error Vite worker import const SmWorker = (await import('./smoldot-worker?worker')).default smoldotRef = startFromWorker(new SmWorker()) diff --git a/src/lib/recipients.svelte.ts b/src/lib/recipients.svelte.ts new file mode 100644 index 0000000..cbd2ae6 --- /dev/null +++ b/src/lib/recipients.svelte.ts @@ -0,0 +1,465 @@ +import { AccountId, Binary } from 'polkadot-api' +import { getClient } from './provider.svelte' +import { getTurnoverLastNMonths, getCurrentReputables } from './accountingApi' +import { convertCcToUsd, ksmToUsdc } from './forex' + +const ENCOINTER_PARA_ID = 1001 +const KSM_SS58_PREFIX = 2 + +const USDC_FOREIGN_LOCATION = { + parents: 2, + interior: { + type: 'X4', + value: [ + { type: 'GlobalConsensus', value: { type: 'Polkadot', value: undefined } }, + { type: 'Parachain', value: 1000 }, + { type: 'PalletInstance', value: 50 }, + { type: 'GeneralIndex', value: 1337n }, + ], + }, +} + +export interface Faucet { + account: string + name: string + dripAmount: bigint + whitelist: string[] | null + freeBalance: bigint + /** Approximated count of unique persons attested every 10 days who could drip + * from this faucet (sum across whitelisted cids; sum across all cids when open). */ + attestedPersons: number + /** USDC value of one drip — `null` if quote unavailable, undefined while loading. */ + dripUsdc: number | null | undefined +} + +export interface Treasury { + cid: string + name: string + /** On-chain community currency symbol (CommunityMetadata.symbol) */ + symbol: string + encointerAccount: string + kahAccount: string + ksmBalance: bigint + usdcBalance: bigint + location?: string + donationsDisabled: boolean + /** Count of regularly active unique persons (current reputables, fetched from + * accounting-backend). `null` once loading completes if unavailable. */ + regularlyActivePersons: number | null + regularlyActivePersonsLoading: boolean + /** Total community-currency turnover over the last 3 full calendar months, + * fetched from accounting-backend. `null` once loading completes if unavailable. */ + turnoverLast3Months: number | null + turnoverLoading: boolean + /** 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 +} + +const TREASURY_FIXTURE: Record = { + u0qj944rhWE: { + name: 'Leu Treasury', + encointerAccount: 'HNJDzJEGaBgWRXz7bjERsRidJFQBnj1AZ2Tn3Q9uRGynhwq', + kahAccount: 'DgdA9qwXxBAtdy9veCR4LZpcbYuMgCSL9XpV7gbELFncV2t', + }, + kygch5kVGq7: { + name: 'Nyota Treasury', + encointerAccount: 'E9KVuDLEtBBWSqhCiKn31VPBBLe33CbYJTrnWAbjszwskWH', + kahAccount: 'G8yWL9B48XnbwC5aYpotqUk7ZTcpP7SGQcykoo7TVQTkhwJ', + }, + s1vrqQL2SD: { + name: 'PayNuQ Treasury', + encointerAccount: 'E2mZ1u2xepTF8nuEQVkrimPVwqtqq1joC56cUwYPftXAEQL', + kahAccount: 'CqCAXF5M51M7xttMuK47TmyuSos8iusFm524ZzaAZnNiner', + }, +} + +const COMMUNITY_INFO: Record = { + u0qj944rhWE: { location: 'Zurich, Switzerland', donationsDisabled: true }, + kygch5kVGq7: { location: 'Dar es Salaam, Tanzania' }, + s1vrqQL2SD: { location: 'Zaria, Nigeria' }, +} + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function encodeBase58(bytes: Uint8Array): string { + let zeros = 0 + while (zeros < bytes.length && bytes[zeros] === 0) zeros++ + let num = 0n + for (const b of bytes) num = num * 256n + BigInt(b) + let out = '' + while (num > 0n) { + out = BASE58_ALPHABET[Number(num % 58n)] + out + num /= 58n + } + return '1'.repeat(zeros) + out +} + +const ksmSs58 = AccountId(KSM_SS58_PREFIX) + +function asBytes(v: unknown): Uint8Array { + if (v instanceof Binary) return v.asBytes() + if (v instanceof Uint8Array) return v + if (typeof v === 'object' && v !== null && 'asBytes' in v && typeof (v as { asBytes: unknown }).asBytes === 'function') { + return (v as { asBytes: () => Uint8Array }).asBytes() + } + if (typeof v === 'string' && /^0x[0-9a-fA-F]*$/.test(v)) { + const hex = v.slice(2) + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out + } + if (Array.isArray(v) && v.every(x => typeof x === 'number')) { + return new Uint8Array(v as number[]) + } + const ctor = (v as { constructor?: { name?: string } })?.constructor?.name ?? typeof v + let sample = '' + try { sample = JSON.stringify(v)?.slice(0, 200) ?? '' } catch { sample = String(v).slice(0, 200) } + throw new Error(`not bytes-like: ctor=${ctor} sample=${sample}`) +} + +function cidToString(cid: { geohash: unknown; digest: unknown }): string { + const geohashStr = new TextDecoder().decode(asBytes(cid.geohash)) + return geohashStr + encodeBase58(asBytes(cid.digest)) +} + +function accountFromKey(arg: unknown): string { + if (typeof arg === 'string') return arg + return ksmSs58.dec(asBytes(arg)) +} + +function ss58ToBytes(ss58: string): Uint8Array { + return ksmSs58.enc(ss58) +} + +function accountToSs58AndBytes(v: unknown): { ss58: string; bytes: Uint8Array } { + if (typeof v === 'string') return { ss58: v, bytes: ss58ToBytes(v) } + const bytes = asBytes(v) + return { ss58: ksmSs58.dec(bytes), bytes } +} + +let faucets = $state([]) +let treasuries = $state([]) +let loading = $state(false) +let lastError = $state(null) +let loadedOnce = $state(false) +let selected = $state>(new Set()) + +interface UnsafeApi { + query: Record Promise + getEntries: () => Promise> + }>> + apis: Record Promise>> +} + +/** + * Returns expected new reputations issued per ceremony cycle (≈ 10 days) per cid. + * This is the attendance of the most recent fully-attested ceremony per cid: + * each new reputation = one fresh, unspent drip permission. We walk cindices + * from newest to oldest within the reputation-lifetime window and pick the + * first non-zero count (skips the in-progress cindex if rewards haven't been + * issued yet). + */ +async function loadReputablesByCid( + encApi: UnsafeApi, + cidsRaw: Array<{ geohash: unknown; digest: unknown }>, +): Promise> { + const lifetimeRaw = await encApi.query.EncointerCeremonies.ReputationLifetime.getValue() as bigint | number + const currentRaw = await encApi.query.EncointerScheduler.CurrentCeremonyIndex.getValue() as bigint | number + const lifetime = Number(lifetimeRaw) + const current = Number(currentRaw) + const minC = Math.max(1, current - lifetime + 1) + + const out = new Map() + for (const cidObj of cidsRaw) { + const cidStr = cidToString(cidObj) + const queries: Array> = [] + for (let c = minC; c <= current; c++) { + queries.push(encApi.query.EncointerCeremonies.ReputationCount.getValue([cidObj, c])) + } + let latestNonZero = 0 + try { + const counts = await Promise.all(queries) + // queries[i] corresponds to cindex (minC + i); iterate newest → oldest + for (let i = counts.length - 1; i >= 0; i--) { + const v = counts[i] + const n = typeof v === 'bigint' ? Number(v) : Number(v ?? 0) + if (n > 0) { latestNonZero = n; break } + } + } catch (err) { + console.warn(`[recipients] reputation count query failed for ${cidStr}`, err) + } + out.set(cidStr, latestNonZero) + } + return out +} + +async function loadFaucets(encApi: UnsafeApi, reputablesByCid: Map): Promise { + const entries = await encApi.query.EncointerFaucet.Faucets.getEntries() + const totalAcrossAllCids = [...reputablesByCid.values()].reduce((a, b) => a + b, 0) + const result: Faucet[] = [] + for (const entry of entries) { + const account = accountFromKey(entry.keyArgs[0]) + const f = entry.value as { + name: unknown + whitelist: Array<{ geohash: unknown; digest: unknown }> | null | undefined + drip_amount: bigint + } + const name = new TextDecoder().decode(asBytes(f.name)) + const whitelist = f.whitelist != null ? f.whitelist.map(cidToString) : null + let freeBalance = 0n + try { + const acc = await encApi.query.System.Account.getValue(account) as { + data: { free: bigint } + } + freeBalance = acc.data.free + } catch (err) { + console.warn(`[recipients] system.account failed for faucet ${name}`, err) + } + const attestedPersons = whitelist == null + ? totalAcrossAllCids + : whitelist.reduce((s, c) => s + (reputablesByCid.get(c) ?? 0), 0) + result.push({ + account, name, dripAmount: f.drip_amount, whitelist, freeBalance, attestedPersons, + dripUsdc: undefined, + }) + } + return result +} + +async function callTreasuriesApi(encApi: UnsafeApi, cidObj: unknown): Promise { + const candidates = ['TreasuriesApi', 'EncointerTreasuriesApi'] + let lastErr: unknown + for (const apiName of candidates) { + const api = encApi.apis[apiName] + if (!api?.get_community_treasury_account_unchecked) continue + try { + return await api.get_community_treasury_account_unchecked(cidObj) + } catch (err) { + lastErr = err + } + } + if (lastErr) throw lastErr + throw new Error(`No matching runtime API among: ${candidates.join(', ')}. Available: ${Object.keys(encApi.apis ?? {}).join(', ')}`) +} + +async function deriveKahAccount(kahApi: UnsafeApi, treasuryBytes: Uint8Array): Promise { + const versions = ['V5', 'V4', 'V3'] as const + for (const v of versions) { + const versionedLoc = { + type: v, + value: { + parents: 1, + interior: { + type: 'X2', + value: [ + { type: 'Parachain', value: ENCOINTER_PARA_ID }, + { type: 'AccountId32', value: { network: undefined, id: Binary.fromBytes(treasuryBytes) } }, + ], + }, + }, + } + try { + const result = await kahApi.apis.LocationToAccountApi.convert_location(versionedLoc) as + | { success: true; value: unknown } + | { success: false; value: unknown } + if (result.success) { + return accountFromKey(result.value) + } + } catch { + // try lower version + } + } + return null +} + +async function loadTreasuries( + encApi: UnsafeApi, + kahApi: UnsafeApi, + cidsRaw: Array<{ geohash: unknown; digest: unknown }>, +): Promise { + const metaEntries = await encApi.query.EncointerCommunities.CommunityMetadata.getEntries() + const nameByCid = new Map() + const symbolByCid = new Map() + for (const entry of metaEntries) { + const cidStr = cidToString(entry.keyArgs[0] as { geohash: unknown; digest: unknown }) + const meta = entry.value as { name: unknown; symbol: unknown } + nameByCid.set(cidStr, new TextDecoder().decode(asBytes(meta.name))) + try { + symbolByCid.set(cidStr, new TextDecoder().decode(asBytes(meta.symbol))) + } catch { + // missing symbol — leave unset + } + } + + const result: Treasury[] = [] + for (const cidObj of cidsRaw) { + const cidStr = cidToString(cidObj) + + const treasuryRaw = await callTreasuriesApi(encApi, cidObj) + const { ss58: encointerAccount, bytes: treasuryBytes } = accountToSs58AndBytes(treasuryRaw) + + const kahAccount = await deriveKahAccount(kahApi, treasuryBytes) ?? '' + + let ksmBalance = 0n + try { + const acc = await encApi.query.System.Account.getValue(encointerAccount) as { + data: { free: bigint } + } + ksmBalance = acc.data.free + } catch (err) { + console.warn(`[recipients] KSM balance query failed for ${cidStr}`, err) + } + + let usdcBalance = 0n + if (kahAccount) { + try { + const fa = await kahApi.query.ForeignAssets.Account.getValue(USDC_FOREIGN_LOCATION, kahAccount) as + | { balance: bigint } + | undefined + usdcBalance = fa?.balance ?? 0n + } catch (err) { + console.warn(`[recipients] USDC balance query failed for ${cidStr}`, err) + } + } + + const fixture = TREASURY_FIXTURE[cidStr] + if (fixture && (fixture.encointerAccount !== encointerAccount || fixture.kahAccount !== kahAccount)) { + console.warn(`[recipients] fixture mismatch for ${cidStr}`, { + expected: fixture, got: { encointerAccount, kahAccount }, + }) + } + + const info = COMMUNITY_INFO[cidStr] ?? {} + result.push({ + cid: cidStr, + name: nameByCid.get(cidStr) ?? cidStr, + symbol: symbolByCid.get(cidStr) ?? '', + encointerAccount, + kahAccount, + ksmBalance, + usdcBalance, + location: info.location, + donationsDisabled: info.donationsDisabled ?? false, + regularlyActivePersons: null, + regularlyActivePersonsLoading: true, + turnoverLast3Months: null, + turnoverLoading: true, + turnoverLast3MonthsUsdc: null, + }) + } + return result +} + +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 fetchRegularlyActiveInto(cid: string) { + const n = await getCurrentReputables(cid) + const t = treasuries.find(x => x.cid === cid) + if (t) { + t.regularlyActivePersons = n + t.regularlyActivePersonsLoading = false + } +} + +async function fetchTurnoverInto(cid: string) { + const v = await getTurnoverLastNMonths(cid, 3) + const t = treasuries.find(x => x.cid === cid) + if (!t) return + t.turnoverLast3Months = v + t.turnoverLoading = false + if (v !== null && v > 0 && t.symbol) { + const usdc = await convertCcToUsd(t.symbol, v) + const after = treasuries.find(x => x.cid === cid) + if (after) after.turnoverLast3MonthsUsdc = usdc + } +} + +export async function loadRecipients() { + const encClient = getClient('encointer') + const kahClient = getClient('kah') + if (!encClient || !kahClient) { + lastError = 'Not connected to chains' + return + } + loading = true + lastError = null + try { + const encApi = encClient.getUnsafeApi() as unknown as UnsafeApi + const kahApi = kahClient.getUnsafeApi() as unknown as UnsafeApi + const cidsRaw = await encApi.query.EncointerCommunities.CommunityIdentifiers.getValue() as Array<{ + geohash: unknown + digest: unknown + }> + let reputablesByCid = new Map() + try { + reputablesByCid = await loadReputablesByCid(encApi, cidsRaw) + } catch (err) { + console.warn('[recipients] reputables load failed; faucets will show 0', errMsg(err), err) + } + let f: Faucet[] = [] + let t: Treasury[] = [] + try { + f = await loadFaucets(encApi, reputablesByCid) + } catch (err) { + console.error('[recipients] loadFaucets threw:', errMsg(err), err) + throw new Error(`loadFaucets: ${errMsg(err)}`) + } + try { + t = await loadTreasuries(encApi, kahApi, cidsRaw) + } catch (err) { + console.error('[recipients] loadTreasuries threw:', errMsg(err), err) + throw new Error(`loadTreasuries: ${errMsg(err)}`) + } + faucets = f + treasuries = t + loadedOnce = true + // Fire turnover + reputables + KSM/USDC quote fetches in parallel; mutate entries as they land. + for (const tr of treasuries) { + void fetchTurnoverInto(tr.cid) + void fetchRegularlyActiveInto(tr.cid) + } + for (const fc of faucets) { + void fetchFaucetDripUsdcInto(fc.account, fc.dripAmount) + } + } catch (err) { + lastError = errMsg(err) + console.error('[recipients] load failed:', errMsg(err), err) + } finally { + loading = false + } +} + +function errMsg(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + try { return JSON.stringify(err) } catch { return String(err) } +} + +export function getFaucets(): Faucet[] { return faucets } +export function getTreasuries(): Treasury[] { return treasuries } +export function isLoadingRecipients(): boolean { return loading } +export function getRecipientsError(): string | null { return lastError } +export function isRecipientsLoaded(): boolean { return loadedOnce } + +export function isSelected(id: string): boolean { return selected.has(id) } + +export function toggleSelected(id: string) { + const next = new Set(selected) + if (next.has(id)) next.delete(id) + else next.add(id) + selected = next +} + +export function clearSelection() { selected = new Set() } + +export function getSelectedIds(): string[] { return [...selected] } + +export function selectionCount(): number { return selected.size } + +export function selectAll(ids: string[]) { selected = new Set(ids) } diff --git a/src/views/DonateView.svelte b/src/views/DonateView.svelte new file mode 100644 index 0000000..ffec666 --- /dev/null +++ b/src/views/DonateView.svelte @@ -0,0 +1,767 @@ + + + + +