diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..f0197dd
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,13 @@
+OGCR-App
+Copyright (C) 2026 TESOBE GmbH
+
+This program is free software: you can redistribute it and/or modify it under the
+terms of the GNU Affero General Public License as published by the Free Software
+Foundation, either version 3 of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License along with
+this program, in the LICENSE file. If not, see .
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 @@
{@render icon()}
{/if}
{#if href}
- {label}
+
+ {label}
{:else}
{label}
{/if}
@@ -67,6 +74,9 @@
{#if secondaryText}
{secondaryText}
{/if}
+ {#if href && browseLabel}
+ {browseLabel}
+ {/if}