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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
22 changes: 21 additions & 1 deletion src/lib/chain/onChain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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');
Expand Down
50 changes: 50 additions & 0 deletions src/lib/chain/onChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -377,6 +393,40 @@ export async function getChainTokens(): Promise<ChainSection[]> {
];
}

/** 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<string, any>[], 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<CreditTotals> {
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
Expand Down
29 changes: 28 additions & 1 deletion src/lib/components/Kpi.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,6 +30,7 @@
tone = 'positive',
accentBar = true,
href,
browseLabel,
class: className = '',
...rest
}: {
Expand All @@ -38,6 +41,7 @@
tone?: Tone;
accentBar?: boolean;
href?: string;
browseLabel?: string;
class?: string;
[key: string]: unknown;
} = $props();
Expand All @@ -53,7 +57,10 @@
<span class="ogcr-kpi__icon" aria-hidden="true">{@render icon()}</span>
{/if}
{#if href}
<a {href} class="ogcr-kpi__label ogcr-kpi__link">{label}</a>
<!-- With a browse link, that is the one tab stop for the destination. -->
<a {href} class="ogcr-kpi__label ogcr-kpi__link" tabindex={browseLabel ? -1 : undefined}
>{label}</a
>
{:else}
<span class="ogcr-kpi__label">{label}</span>
{/if}
Expand All @@ -67,6 +74,9 @@
{#if secondaryText}
<p class="ogcr-kpi__secondary">{secondaryText}</p>
{/if}
{#if href && browseLabel}
<a {href} class="ogcr-kpi__browse">{browseLabel}</a>
{/if}
</article>

<style>
Expand Down Expand Up @@ -157,6 +167,20 @@
text-decoration: underline;
}

.ogcr-kpi__browse {
margin-top: var(--space-xs);
font-family: var(--font-family-default);
font-weight: 500;
font-size: var(--font-size-m);
color: var(--interaction-primary-default);
text-decoration: none;
}
.ogcr-kpi__browse:hover,
.ogcr-kpi__browse:focus-visible {
color: var(--interaction-primary-hover);
text-decoration: underline;
}

.ogcr-kpi__secondary {
margin: 0;
font-family: var(--font-family-default);
Expand All @@ -175,6 +199,9 @@
:global([data-mode='dark']) .ogcr-kpi__value {
color: var(--color-primary-200);
}
:global([data-mode='dark']) .ogcr-kpi__browse {
color: var(--color-primary-300);
}
:global([data-mode='dark']) .ogcr-kpi__label,
:global([data-mode='dark']) .ogcr-kpi__secondary {
color: var(--color-surface-400);
Expand Down
13 changes: 12 additions & 1 deletion src/lib/obp/domainApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest';
import { publicGets } from './domainApi';
import { apiExplorerDomainApiLink, publicGets } from './domainApi';

describe('apiExplorerDomainApiLink', () => {
it("opens API Explorer on the Domain API's own version", () => {
expect(apiExplorerDomainApiLink('https://explorer.example.org', 'crcf/ogcr/v1')).toBe(
'https://explorer.example.org/resource-docs/DomainAPI~crcf~ogcr~v1'
);
expect(apiExplorerDomainApiLink('http://localhost:5173/', 'crcf/ogcr/v1')).toBe(
'http://localhost:5173/resource-docs/DomainAPI~crcf~ogcr~v1'
);
});
});

const login = [{ DirectLogin: [] }, { OAuth2: [] }];

Expand Down
9 changes: 9 additions & 0 deletions src/lib/obp/domainApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
* that Domain API's endpoints, at their published paths.
*/

/**
* Where API Explorer shows a Domain API: it lists each publicly discoverable Domain API as a
* version of its own, keyed by its base path with `~` for `/` (a base path cannot contain `~`).
*/
export function apiExplorerDomainApiLink(apiExplorerUrl: string, basePath: string): string {
const base = apiExplorerUrl.endsWith('/') ? apiExplorerUrl : `${apiExplorerUrl}/`;
return new URL(`resource-docs/DomainAPI~${basePath.split('/').join('~')}`, base).toString();
}

/** A GET endpoint anyone may call, at its path under the Domain API's base path. */
export type PublicGet = {
path: string;
Expand Down
27 changes: 27 additions & 0 deletions src/lib/obp/entityDescriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { descriptionFromDoc, entityTag } from './entityDescriptions';

describe('entityTag', () => {
it('capitalises the first letter and adds the bank id', () => {
expect(entityTag('parcel_on_chain', 'ogcr')).toBe('_Parcel_on_chain(ogcr)');
});
});

describe('descriptionFromDoc', () => {
it('keeps only the definition description', () => {
const doc = {
description_markdown:
"Create new parcel.\n\nParcel' means a geospatially defined unit of land. This Dynamic Entity is part of OGCR.\n\n\n**Property List:**\n\n* parcel_id: id\n\nMethodRouting settings example: ..."
};
expect(descriptionFromDoc(doc, 'parcel')).toBe(
"Parcel' means a geospatially defined unit of land."
);
});

it('is null when there is no description', () => {
expect(
descriptionFromDoc({ description_markdown: 'Create new x.\n\n**Property List:**' }, 'x')
).toBeNull();
expect(descriptionFromDoc({}, 'x')).toBeNull();
});
});
65 changes: 65 additions & 0 deletions src/lib/obp/entityDescriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { env } from '$env/dynamic/public';
import { ENTITY_ROLE_BANK_ID } from '$lib/constants/entities';

/**
* Dynamic Entity descriptions, as written in each entity's definition.
*
* The definitions endpoints need an admin Role, but OBP copies each definition's
* description into the entity's generated resource docs, which are public. So this reads
* those, filtered by the entities' tags (`_Parcel_on_chain(ogcr)`) so only their docs come
* back rather than the whole space's (several MB).
*/

interface ResourceDoc {
request_verb?: string;
specified_url?: string;
description_markdown?: string;
}

/** OBP tags an entity's generated endpoints `_<Entity_name>(<bank id>)`. */
export function entityTag(entity: string, bankId: string): string {
return `_${entity.charAt(0).toUpperCase()}${entity.slice(1)}(${bankId})`;
}

/**
* The definition's description, from the entity's create (POST) doc: OBP wraps it in a
* "Create new <entity>." line, then appends the property list, the routing example and a
* "This Dynamic Entity is part of …" line, all of which are dropped here.
*/
export function descriptionFromDoc(doc: ResourceDoc, entity: string): string | null {
const markdown = doc.description_markdown ?? '';
const text = markdown
.split(/\*\*Property List|MethodRouting settings example/)[0]
.replace(new RegExp(`^\\s*Create new ${entity}\\.`), '')
.replace(/This Dynamic Entity is part of [^.]*\./g, '')
.replace(/\s+/g, ' ')
.trim();
return text || null;
}

/** entity -> description, for those of `entities` whose docs OBP returned. Never throws. */
export async function getEntityDescriptions(
entities: string[],
fetchFn: typeof fetch = fetch
): Promise<Record<string, string>> {
const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? '';
const bank = encodeURIComponent(ENTITY_ROLE_BANK_ID);
const tags = entities.map((e) => encodeURIComponent(entityTag(e, ENTITY_ROLE_BANK_ID))).join(',');
const url = `${base}/obp/v6.0.0/banks/${bank}/resource-docs/v6.0.0/obp?content=dynamic&tags=${tags}`;
try {
const response = await fetchFn(url);
if (!response.ok) return {};
const docs = ((await response.json())?.resource_docs ?? []) as ResourceDoc[];
const descriptions: Record<string, string> = {};
for (const entity of entities) {
const create = docs.find(
(d) => d.request_verb === 'POST' && (d.specified_url ?? '').endsWith(`/${entity}`)
);
const text = create ? descriptionFromDoc(create, entity) : null;
if (text) descriptions[entity] = text;
}
return descriptions;
} catch {
return {};
}
}
Loading
Loading