From 9898737c457443f202f5c692673e4f8a55e2f29b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 14:23:59 +0200 Subject: [PATCH 01/11] first try for donations --- .env | 2 + src/App.svelte | 32 +- src/components/Header.svelte | 31 +- src/components/RecipientCard.svelte | 188 ++++++++++ src/lib/accountingApi.ts | 65 ++++ src/lib/donate.svelte.ts | 273 ++++++++++++++ src/lib/forex.ts | 64 ++++ src/lib/provider.svelte.ts | 1 - src/lib/recipients.svelte.ts | 429 ++++++++++++++++++++++ src/views/DonateView.svelte | 530 ++++++++++++++++++++++++++++ 10 files changed, 1598 insertions(+), 17 deletions(-) create mode 100644 .env create mode 100644 src/components/RecipientCard.svelte create mode 100644 src/lib/accountingApi.ts create mode 100644 src/lib/donate.svelte.ts create mode 100644 src/lib/forex.ts create mode 100644 src/lib/recipients.svelte.ts create mode 100644 src/views/DonateView.svelte diff --git a/.env b/.env new file mode 100644 index 0000000..50090a0 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +#VITE_ACCOUNTING_API_URL=https://api.encointer.org/v1 +VITE_ACCOUNTING_API_URL=http://127.0.0.1:8081/v1 diff --git a/src/App.svelte b/src/App.svelte index e8ca8f8..48b1fda 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,13 +2,22 @@ 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 { + if (hash === '#donate') return 'donate' + if (hash === '#transfer') return 'transfer' + return 'home' + } + + let view = $state(viewFromHash(window.location.hash)) let transferParams = $state(null) // Initialize provider + wallet on mount @@ -40,22 +49,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,6 +70,8 @@
{#if view === 'transfer' && transferParams} + {:else if view === 'donate'} + {:else} {/if} diff --git a/src/components/Header.svelte b/src/components/Header.svelte index f6e596c..3c42338 100644 --- a/src/components/Header.svelte +++ b/src/components/Header.svelte @@ -7,6 +7,13 @@ let showWallet = $state(false) let showSettings = $state(false) + let currentHash = $state(window.location.hash) + + $effect(() => { + const onHashChange = () => { currentHash = window.location.hash } + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }) const wallet = $derived(getWalletState()) @@ -14,7 +21,11 @@
- Transfer +
@@ -73,9 +84,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..b302029 --- /dev/null +++ b/src/components/RecipientCard.svelte @@ -0,0 +1,188 @@ + + + + + diff --git a/src/lib/accountingApi.ts b/src/lib/accountingApi.ts new file mode 100644 index 0000000..260d6a3 --- /dev/null +++ b/src/lib/accountingApi.ts @@ -0,0 +1,65 @@ +/// + +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 +} + +/** + * 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..38d42f1 --- /dev/null +++ b/src/lib/donate.svelte.ts @@ -0,0 +1,273 @@ +import { Builder } from '@paraspell/sdk' +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' + +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' } + | { step: 'error'; message: string } + +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 } + } +} + +interface UnsignedTx { + decodedCall: unknown + getEstimatedFees: (sender: string) => Promise + signAndSubmit: (signer: PolkadotSigner) => Promise +} + +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 }, + ], + }, +} + +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 + + 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 +} + +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') +} + +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 + } + + if (calls.length > 1) { + const batch = await buildBatch(params.source, calls) + if (batch) { + state = { step: 'executing', mode: 'batch', current: 0, total: 1 } + try { + await batch.signAndSubmit(signer) + state = { step: 'success' } + 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) + } + } + } + + for (let i = 0; i < calls.length; i++) { + state = { step: 'executing', mode: 'sequential', current: i, total: calls.length } + try { + await calls[i].signAndSubmit(signer) + } 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' } + 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/forex.ts b/src/lib/forex.ts new file mode 100644 index 0000000..91e6779 --- /dev/null +++ b/src/lib/forex.ts @@ -0,0 +1,64 @@ +/// + +// 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 +} 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..4fa147a --- /dev/null +++ b/src/lib/recipients.svelte.ts @@ -0,0 +1,429 @@ +import { AccountId, Binary } from 'polkadot-api' +import { getClient } from './provider.svelte' +import { getTurnoverLastNMonths } from './accountingApi' +import { convertCcToUsd } 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 +} + +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 + /** Approximated count of unique persons attested every 10 days in this community. */ + attestedPersons: number + /** 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>> +} + +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 max = 0 + try { + const counts = await Promise.all(queries) + for (const v of counts) { + const n = typeof v === 'bigint' ? Number(v) : Number(v ?? 0) + if (n > max) max = n + } + } catch (err) { + console.warn(`[recipients] reputation count query failed for ${cidStr}`, err) + } + out.set(cidStr, max) + } + 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 }) + } + 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 }>, + reputablesByCid: Map, +): 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, + attestedPersons: reputablesByCid.get(cidStr) ?? 0, + turnoverLast3Months: null, + turnoverLoading: true, + turnoverLast3MonthsUsdc: null, + }) + } + return result +} + +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, reputablesByCid) + } catch (err) { + console.error('[recipients] loadTreasuries threw:', errMsg(err), err) + throw new Error(`loadTreasuries: ${errMsg(err)}`) + } + faucets = f + treasuries = t + loadedOnce = true + // Fire turnover fetches in parallel; mutate per-treasury entries as they land. + for (const tr of treasuries) { + void fetchTurnoverInto(tr.cid) + } + } 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..684e2c0 --- /dev/null +++ b/src/views/DonateView.svelte @@ -0,0 +1,530 @@ + + + + + From 404ce871ec2e93ff7bcf390f67ffcfb94fe9644c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 15:03:03 +0200 Subject: [PATCH 02/11] replace N XCM batch with a single XCM distributing deposits at destination --- src/lib/donate.svelte.ts | 159 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/lib/donate.svelte.ts b/src/lib/donate.svelte.ts index 38d42f1..6588749 100644 --- a/src/lib/donate.svelte.ts +++ b/src/lib/donate.svelte.ts @@ -1,10 +1,13 @@ 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 type DonateState = | { step: 'idle' } | { step: 'estimating' } @@ -65,6 +68,7 @@ interface SrcApi { 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 } } } @@ -87,6 +91,9 @@ const USDC_LOCATION = { }, } +/** 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, @@ -145,6 +152,24 @@ async function buildPerRecipientCalls( 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] @@ -158,6 +183,140 @@ async function buildPerRecipientCalls( 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) From 233632dab01a82c6b2b27323680d81cab1fe41f0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 15:37:23 +0200 Subject: [PATCH 03/11] provide subscan links as confirmation --- src/lib/donate.svelte.ts | 61 +++++++++++++++++++++++++++++++++---- src/views/DonateView.svelte | 45 ++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/lib/donate.svelte.ts b/src/lib/donate.svelte.ts index 6588749..f172047 100644 --- a/src/lib/donate.svelte.ts +++ b/src/lib/donate.svelte.ts @@ -8,14 +8,30 @@ 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' } + | { 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 interface DonateRecipient { /** Stable identifier (account address) — used for selection */ id: string @@ -75,7 +91,7 @@ interface SrcApi { interface UnsignedTx { decodedCall: unknown getEstimatedFees: (sender: string) => Promise - signAndSubmit: (signer: PolkadotSigner) => Promise + signAndSubmit: (signer: PolkadotSigner) => Promise<{ txHash: string } | unknown> } const USDC_LOCATION = { @@ -375,6 +391,15 @@ function isUserCancel(err: unknown): boolean { 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, @@ -387,13 +412,17 @@ export async function executeDonate( 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 { - await batch.signAndSubmit(signer) - state = { step: 'success' } + 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)) { @@ -403,19 +432,39 @@ export async function executeDonate( 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 { - await calls[i].signAndSubmit(signer) + 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' } + state = { step: 'success', submitted } return true } catch (err) { state = { step: 'error', message: err instanceof Error ? err.message : 'Execution failed' } diff --git a/src/views/DonateView.svelte b/src/views/DonateView.svelte index 684e2c0..24bca88 100644 --- a/src/views/DonateView.svelte +++ b/src/views/DonateView.svelte @@ -3,7 +3,7 @@ import { CHAINS, getDecimals } from '../lib/chains' import { getWalletState } from '../lib/wallet.svelte' import { getBalanceFor } from '../lib/balances.svelte' - import { formatBalance, parseAmount } from '../lib/format' + import { formatBalance, parseAmount, truncateAddress } from '../lib/format' import { loadRecipients, getFaucets, @@ -26,6 +26,7 @@ recipientFromFaucet, recipientFromTreasury, destinationChain, + subscanUrl, ALLOWED_SOURCES, type DonateRecipient, } from '../lib/donate.svelte' @@ -322,6 +323,20 @@ {#if source && source !== dest}

Cross-chain XCM transfers will land at the destination in ~6 minutes.

{/if} + {#if txState.submitted.length > 0} + + {/if} {:else if txState.step === 'error'} @@ -516,6 +531,34 @@ padding: 1.5rem; } + .submitted-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + width: 100%; + font-size: 0.85rem; + } + .submitted-list li { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + align-items: baseline; + } + .submitted-list .hash { + font-family: var(--font-mono); + } + .subscan-link { + color: var(--color-accent); + text-decoration: none; + } + .subscan-link:hover { + text-decoration: underline; + } + .success-text { font-size: 1.1rem; font-weight: 600; From c463097550d90e463a19fc773256f0fd66b9dee2 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 15:46:54 +0200 Subject: [PATCH 04/11] fix env for GH pages --- .env | 2 -- .env.production | 1 + .gitignore | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 .env create mode 100644 .env.production diff --git a/.env b/.env deleted file mode 100644 index 50090a0..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -#VITE_ACCOUNTING_API_URL=https://api.encointer.org/v1 -VITE_ACCOUNTING_API_URL=http://127.0.0.1:8081/v1 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/* From 53cf284bc363b4cdf4b5450bf586166b7305cb7b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 16:17:22 +0200 Subject: [PATCH 05/11] cosmetics and fixes --- src/App.svelte | 26 +++ src/components/RecipientCard.svelte | 240 +++++++++++++++++++--------- src/lib/accountingApi.ts | 31 ++++ src/lib/donate.svelte.ts | 6 + src/lib/forex.ts | 59 +++++++ src/lib/recipients.svelte.ts | 62 +++++-- src/views/DonateView.svelte | 2 - 7 files changed, 339 insertions(+), 87 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 48b1fda..9dfd1f4 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -77,6 +77,13 @@ {/if}
+ + diff --git a/src/components/RecipientCard.svelte b/src/components/RecipientCard.svelte index b302029..eb735f0 100644 --- a/src/components/RecipientCard.svelte +++ b/src/components/RecipientCard.svelte @@ -1,6 +1,7 @@ - {:else} @@ -54,39 +103,59 @@ donations disabled {/if} - {formatBalance(props.data.usdcBalance, 6)} USDC -
- - cid {props.data.cid} - {#if props.data.location}— {props.data.location}{/if} - - {formatBalance(props.data.ksmBalance, 12)} KSM +
+ cid {props.data.cid}{#if props.data.location} — {props.data.location}{/if}
-
- KAH: {truncateAddress(props.data.kahAccount || '—')} - - ~{props.data.attestedPersons} unique persons attested every 10 days - +
+ 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} - — +
+ 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} - +
diff --git a/src/lib/accountingApi.ts b/src/lib/accountingApi.ts index 260d6a3..2ab8260 100644 --- a/src/lib/accountingApi.ts +++ b/src/lib/accountingApi.ts @@ -39,6 +39,37 @@ export function lastFullMonths(n: number, now: Date = new Date()): Array<{ year: 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"). diff --git a/src/lib/donate.svelte.ts b/src/lib/donate.svelte.ts index f172047..45cca3f 100644 --- a/src/lib/donate.svelte.ts +++ b/src/lib/donate.svelte.ts @@ -32,6 +32,12 @@ export function subscanUrl(chain: ChainId, txHash: string): string | 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 diff --git a/src/lib/forex.ts b/src/lib/forex.ts index 91e6779..93fa011 100644 --- a/src/lib/forex.ts +++ b/src/lib/forex.ts @@ -1,5 +1,7 @@ /// +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). @@ -62,3 +64,60 @@ export async function convertCcToUsd(symbol: string, ccAmount: number): Promise< 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. +const KSM_LOCATION = { parents: 1, interior: 'Here' } +const USDC_KAH_LOCATION = { + parents: 2, + interior: { + X4: [ + { GlobalConsensus: { Polkadot: null } }, + { Parachain: 1000 }, + { PalletInstance: 50 }, + { GeneralIndex: 1337 }, + ], + }, +} +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/recipients.svelte.ts b/src/lib/recipients.svelte.ts index 4fa147a..cbd2ae6 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 } from './accountingApi' -import { convertCcToUsd } from './forex' +import { getTurnoverLastNMonths, getCurrentReputables } from './accountingApi' +import { convertCcToUsd, ksmToUsdc } from './forex' const ENCOINTER_PARA_ID = 1001 const KSM_SS58_PREFIX = 2 @@ -28,6 +28,8 @@ export interface Faucet { /** 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 { @@ -41,8 +43,10 @@ export interface Treasury { usdcBalance: bigint location?: string donationsDisabled: boolean - /** Approximated count of unique persons attested every 10 days in this community. */ - attestedPersons: number + /** 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 @@ -149,6 +153,14 @@ interface UnsafeApi { 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 }>, @@ -166,17 +178,19 @@ async function loadReputablesByCid( for (let c = minC; c <= current; c++) { queries.push(encApi.query.EncointerCeremonies.ReputationCount.getValue([cidObj, c])) } - let max = 0 + let latestNonZero = 0 try { const counts = await Promise.all(queries) - for (const v of counts) { + // 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 > max) max = n + if (n > 0) { latestNonZero = n; break } } } catch (err) { console.warn(`[recipients] reputation count query failed for ${cidStr}`, err) } - out.set(cidStr, max) + out.set(cidStr, latestNonZero) } return out } @@ -206,7 +220,10 @@ async function loadFaucets(encApi: UnsafeApi, reputablesByCid: Map s + (reputablesByCid.get(c) ?? 0), 0) - result.push({ account, name, dripAmount: f.drip_amount, whitelist, freeBalance, attestedPersons }) + result.push({ + account, name, dripAmount: f.drip_amount, whitelist, freeBalance, attestedPersons, + dripUsdc: undefined, + }) } return result } @@ -261,7 +278,6 @@ async function loadTreasuries( encApi: UnsafeApi, kahApi: UnsafeApi, cidsRaw: Array<{ geohash: unknown; digest: unknown }>, - reputablesByCid: Map, ): Promise { const metaEntries = await encApi.query.EncointerCommunities.CommunityMetadata.getEntries() const nameByCid = new Map() @@ -326,7 +342,8 @@ async function loadTreasuries( usdcBalance, location: info.location, donationsDisabled: info.donationsDisabled ?? false, - attestedPersons: reputablesByCid.get(cidStr) ?? 0, + regularlyActivePersons: null, + regularlyActivePersonsLoading: true, turnoverLast3Months: null, turnoverLoading: true, turnoverLast3MonthsUsdc: null, @@ -335,6 +352,21 @@ async function loadTreasuries( 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) @@ -379,7 +411,7 @@ export async function loadRecipients() { throw new Error(`loadFaucets: ${errMsg(err)}`) } try { - t = await loadTreasuries(encApi, kahApi, cidsRaw, reputablesByCid) + t = await loadTreasuries(encApi, kahApi, cidsRaw) } catch (err) { console.error('[recipients] loadTreasuries threw:', errMsg(err), err) throw new Error(`loadTreasuries: ${errMsg(err)}`) @@ -387,9 +419,13 @@ export async function loadRecipients() { faucets = f treasuries = t loadedOnce = true - // Fire turnover fetches in parallel; mutate per-treasury entries as they land. + // 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) diff --git a/src/views/DonateView.svelte b/src/views/DonateView.svelte index 24bca88..399f737 100644 --- a/src/views/DonateView.svelte +++ b/src/views/DonateView.svelte @@ -493,8 +493,6 @@ display: flex; flex-direction: column; gap: 0.4rem; - max-height: 60vh; - overflow-y: auto; } .submit-btn { width: 100%; margin-top: 0.5rem; } From f1f68b3a3bebc81f5d8bc406106248d343d99221 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 5 May 2026 16:32:11 +0200 Subject: [PATCH 06/11] add audit account verify snippet --- src/components/RecipientCard.svelte | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/RecipientCard.svelte b/src/components/RecipientCard.svelte index eb735f0..9f2a256 100644 --- a/src/components/RecipientCard.svelte +++ b/src/components/RecipientCard.svelte @@ -2,6 +2,7 @@ import type { Faucet, Treasury } from '../lib/recipients.svelte' import { formatBalance, truncateAddress } from '../lib/format' import { subscanAccountUrl } from '../lib/donate.svelte' + import AddressProofModal from './AddressProofModal.svelte' type FaucetProps = { kind: 'faucet'; data: Faucet; selected: boolean; disabled?: boolean; onToggle: () => void } type TreasuryProps = { kind: 'treasury'; data: Treasury; selected: boolean; disabled?: boolean; onToggle: () => void } @@ -15,6 +16,15 @@ let copied = $state(false) let copyTimeout: ReturnType | null = null + let showProof = $state(false) + + function openProof(e: MouseEvent) { + e.stopPropagation() + showProof = true + } + function closeProof() { + showProof = false + } function toggle() { if (!isDisabled) props.onToggle() @@ -92,6 +102,13 @@ aria-label="Copy full address" onclick={copyAddr} >{copied ? '✓' : '⧉'} +
{:else} @@ -143,6 +160,13 @@ aria-label="Copy full address" onclick={copyAddr} >{copied ? '✓' : '⧉'} + {#if subscanLink} +{#if showProof} + {#if props.kind === 'faucet'} + + {:else} + + {/if} +{/if} + 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/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}` +}