From 072807bf4863c78ce3937bf155a4571bb3a35685 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Oct 2026 18:56:33 +0200 Subject: [PATCH 1/2] Entity descriptions and links to API Manager --- src/lib/chain/onChain.test.ts | 22 ++++++++- src/lib/chain/onChain.ts | 50 ++++++++++++++++++++ src/lib/components/Kpi.svelte | 29 +++++++++++- src/lib/obp/domainApi.test.ts | 13 +++++- src/lib/obp/domainApi.ts | 9 ++++ src/lib/obp/entityDescriptions.test.ts | 27 +++++++++++ src/lib/obp/entityDescriptions.ts | 65 ++++++++++++++++++++++++++ src/routes/chain/+page.server.ts | 49 +++++++++++++++++-- src/routes/chain/+page.svelte | 42 +++++++++++++++++ src/routes/domain-api/+page.server.ts | 26 +++++++++-- src/routes/domain-api/+page.svelte | 4 ++ src/routes/registry/+page.server.ts | 18 ++++--- src/routes/registry/+page.svelte | 53 ++++++++++++++++----- 13 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 src/lib/obp/entityDescriptions.test.ts create mode 100644 src/lib/obp/entityDescriptions.ts diff --git a/src/lib/chain/onChain.test.ts b/src/lib/chain/onChain.test.ts index 0c7b693..a985cd1 100644 --- a/src/lib/chain/onChain.test.ts +++ b/src/lib/chain/onChain.test.ts @@ -22,7 +22,8 @@ vi.mock('$lib/obp/requests', () => ({ } })); -const { getChainTokens, getChainSyncStatus, formatUnits, idFromUri } = await import('./onChain'); +const { getChainTokens, getChainSyncStatus, getCreditTotals, formatUnits, idFromUri } = + await import('./onChain'); beforeEach(() => { for (const k of Object.keys(lists)) delete lists[k]; @@ -154,6 +155,25 @@ describe('getChainTokens', () => { }); }); +describe('getCreditTotals', () => { + it('sums issued batch amounts and held balances, in tonnes', async () => { + lists['carbon_credit_batch_on_chain'] = [ + { amount: '6500000000000000000', credit_decimals: '18' }, + { amount: '5500000000000000000', credit_decimals: '18' } + ]; + lists['carbon_credit_balance_on_chain'] = [ + { balance: '6500000000000000000', decimals: '18' }, + { balance: '2000000000000000000', decimals: '18' } + ]; + expect(await getCreditTotals()).toEqual({ issued: '12', active: '8.5' }); + }); + + it('is zero with no records, and null for a mirror that cannot be read', async () => { + delete lists['carbon_credit_balance_on_chain']; + expect(await getCreditTotals()).toEqual({ issued: '0', active: null }); + }); +}); + describe('formatUnits', () => { it('scales base units by the decimals and trims trailing zeros', () => { expect(formatUnits('6500000000000000000', 18)).toBe('6.5'); diff --git a/src/lib/chain/onChain.ts b/src/lib/chain/onChain.ts index 3f2d296..ff59fe4 100644 --- a/src/lib/chain/onChain.ts +++ b/src/lib/chain/onChain.ts @@ -62,6 +62,22 @@ export interface ChainToken { export type ChainSectionKey = 'parcel' | 'activity' | 'certification' | 'batch' | 'balance'; +/** + * Each section's chain mirror entity, and the registry entity it mirrors from. The + * mirrors' descriptions say how they are filled; the source's says what the record is. + * A balance has no registry counterpart: it exists only on chain. + */ +export const CHAIN_SECTION_ENTITIES: Record< + ChainSectionKey, + { entity: string; sourceEntity: string | null } +> = { + parcel: { entity: ENTITY_PARCEL_ON_CHAIN, sourceEntity: 'parcel' }, + activity: { entity: ENTITY_ACTIVITY_ON_CHAIN, sourceEntity: 'activity' }, + certification: { entity: ENTITY_CERTIFICATION_ON_CHAIN, sourceEntity: 'certificate_of_compliance' }, + batch: { entity: ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, sourceEntity: 'carbon_credit_batch' }, + balance: { entity: ENTITY_CARBON_CREDIT_BALANCE_ON_CHAIN, sourceEntity: null } +}; + /** Every token of one type, newest block first. */ export interface ChainSection { key: ChainSectionKey; @@ -377,6 +393,40 @@ export async function getChainTokens(): Promise { ]; } +/** Total credits, in tonnes CO2e: `issued` is what the batches were minted with, `active` + * is what is held anywhere on chain (in batch accounts or wallets), so it drops when + * credits are retired (burned). Null when the mirror cannot be read. */ +export interface CreditTotals { + issued: string | null; + active: string | null; +} + +/** Sum of a base-unit field across records, as a decimal string. All CarbonCredit + * amounts share the token's decimals, so the first record's decimals apply to all. */ +function sumUnits(records: Record[], field: string, decimalsField: string): string { + let total = 0n; + for (const r of records) { + try { + total += BigInt(String(r[field] ?? 0)); + } catch { + // A malformed amount is left out rather than failing the whole total. + } + } + return formatUnits(total.toString(), records[0]?.[decimalsField] ?? 18) ?? '0'; +} + +/** Issued and active credit totals, read anonymously from the public chain mirrors. */ +export async function getCreditTotals(): Promise { + const [batches, balances] = await Promise.all([ + listPublicEntity(ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN).catch(() => null), + listPublicEntity(ENTITY_CARBON_CREDIT_BALANCE_ON_CHAIN).catch(() => null) + ]); + return { + issued: batches ? sumUnits(batches, 'amount', 'credit_decimals') : null, + active: balances ? sumUnits(balances, 'balance', 'decimals') : null + }; +} + /** The four benefit fields a monitoring verification can carry, and the suffix * of the carbon_credit_batch_id each one mints as. Taken from the tokenizer's * MonitoringVerification.CreditBatches(): a batch is minted per field with a diff --git a/src/lib/components/Kpi.svelte b/src/lib/components/Kpi.svelte index 1679868..0122402 100644 --- a/src/lib/components/Kpi.svelte +++ b/src/lib/components/Kpi.svelte @@ -15,6 +15,8 @@ * imply a judgement we have no data to make. * * `href` turns the label and value into links to the page behind the figure. + * `browseLabel` (an app extension, not in the design system) adds a visible + * "Browse …" link to the same page at the foot of the tile. */ import type { Snippet } from 'svelte'; @@ -28,6 +30,7 @@ tone = 'positive', accentBar = true, href, + browseLabel, class: className = '', ...rest }: { @@ -38,6 +41,7 @@ tone?: Tone; accentBar?: boolean; href?: string; + browseLabel?: string; class?: string; [key: string]: unknown; } = $props(); @@ -53,7 +57,10 @@ {/if} {#if href} - {label} + + {label} {:else} {label} {/if} @@ -67,6 +74,9 @@ {#if secondaryText}

{secondaryText}

{/if} + {#if href && browseLabel} + {browseLabel} + {/if}