diff --git a/chain_integration.md b/chain_integration.md index ab45982..abe7549 100644 --- a/chain_integration.md +++ b/chain_integration.md @@ -16,11 +16,11 @@ over the registry. So the chain reaches the app indirectly: ``` -OGCR chain (EVM, chain id 2025) +OGCR chain (EVM, chain id 20253) │ read (no private key) ▼ -OGCR-chain-cache Go service, one-shot mirror - │ write via DirectLogin +OGCR-chain-cache Go service, mirrors on a loop (serve mode) + │ write as a Platform App (OAuth2 client credentials) ▼ OBP dynamic entities *_on_chain │ read via OBP API @@ -37,8 +37,8 @@ entity first. That constraint shapes the heartbeat design in section 4. |---|---|---| | `parcel_on_chain` | `parcel_id` | ParcelNFT | | `activity_on_chain` | `activity_id` | ActivityNFT | -| `certification_on_chain` | `certification_of_compliance_id` | CertificationNFT | -| `carbon_credit_batch_on_chain` | `batch_key` | CarbonCreditBatchNFT | +| `certification_on_chain` | `certificate_of_compliance_id` | CertificationNFT | +| `carbon_credit_batch_on_chain` | `carbon_credit_batch_id` (`:`) | CarbonCreditBatchNFT | | `carbon_credit_balance_on_chain` | `owner_address` | CarbonCredit (ERC-20) | The first four are built from `*Minted` events, so each record is a faithful @@ -206,15 +206,15 @@ The cacher writes one record per run to `chain_sync_status`: | Field | Type | Meaning | |---|---|---| -| `sync_key` | string | Business key, one record per chain, e.g. `chain-2025` | +| `sync_key` | string | Business key, one record per chain, e.g. `chain-20253` | | `chain_id` | integer | EVM chain id the run read | | `head_block` | integer | Chain head at the end of the run | | `synced_at` | string | ISO-8601 timestamp the run finished | -| `run_status` | string | `ok` or `partial`, when some upserts failed | +| `run_status` | string | `ok` or `partial`, when some upserts failed. A token the cacher skips because its activity is not in this OBP (such as the chain's example token) does not make a run partial | | `parcel_count` … | integer | Records mirrored per entity in that run | A string business key for the same reason `carbon_credit_batch_on_chain` uses -`batch_key`: records are matched with a server-side query filter, which is +`carbon_credit_batch_id`: records are matched with a server-side query filter, which is dependable for a string field and not for an integer one. Two behaviours matter more than the fields: @@ -266,8 +266,8 @@ old newest block is a quiet chain rather than a broken one, so the two signals are not confused. Identifiers in that table link to a page in the app where one exists. Activities -link to their own page; credit batches and certifications resolve through -`activity_nft_id` to the activity they belong to. Records with no page, such as +link to their own page; credit batches and certifications carry the +`activity_id` they belong to and link to that. Records with no page, such as parcels, fall back to the registry URL the token itself carries on-chain. ### Transactions and the block explorer diff --git a/src/lib/chain/backlogRules.test.ts b/src/lib/chain/backlogRules.test.ts index ea09baf..2a8f85d 100644 --- a/src/lib/chain/backlogRules.test.ts +++ b/src/lib/chain/backlogRules.test.ts @@ -18,7 +18,7 @@ beforeEach(() => { }); describe('credit batch backlog', () => { - it('expects one batch per positive benefit amount, keyed by the activity NFT id', async () => { + it('expects one batch per positive benefit amount, keyed by verification id and unit', async () => { lists['activity_monitoring_period_verification'] = [ { activity_monitoring_period_verification_id: 'mv1', @@ -30,35 +30,36 @@ describe('credit batch backlog', () => { carbon_farming_temporary_net_carbon_removal_benefit: 0 } ]; - lists['activity_on_chain'] = [{ activity_id: 'a1', token_id: '7' }]; - lists['carbon_credit_batch_on_chain'] = [ - { batch_key: '7:permanent_net_carbon_removal_benefit' } - ]; + lists['carbon_credit_batch_on_chain'] = [{ carbon_credit_batch_id: 'mv1:permanent_removal' }]; const b = await getTokenizationBacklog('token'); const batches = b.entries.find((e) => e.label === 'Credit batches')!; expect(batches.expected).toBe(2); expect(batches.onChain).toBe(1); - expect(batches.pending).toEqual(['7:carbon_farming_net_soil_emission_reduction_benefit']); + expect(batches.pending).toEqual(['mv1:soil_emission_reduction']); }); - it('shows a batch as blocked when its activity is not tokenized yet', async () => { + it('ignores unverified monitoring periods', async () => { lists['activity_monitoring_period_verification'] = [ - { activity_id: 'a2', status_code: 'verified', permanent_net_carbon_removal_benefit: 3 } + { activity_id: 'a3', status_code: 'pending', permanent_net_carbon_removal_benefit: 5 } ]; - lists['activity_on_chain'] = []; // ActivityNFT not minted, tokenizer defers - const b = await getTokenizationBacklog('token'); - const batches = b.entries.find((e) => e.label === 'Credit batches')!; - expect(batches.pending).toEqual(['a2:permanent_net_carbon_removal_benefit (awaiting ActivityNFT)']); + expect(b.entries.find((e) => e.label === 'Credit batches')!.expected).toBe(0); }); - it('ignores unverified monitoring periods', async () => { - lists['activity_monitoring_period_verification'] = [ - { activity_id: 'a3', status_code: 'pending', permanent_net_carbon_removal_benefit: 5 } + it('expects an activity only once it has a certificate of compliance', async () => { + lists['activity_verification'] = [ + { activity_id: 'a1', status_code: 'verified' }, + { activity_id: 'a2', status_code: 'verified' } ]; + lists['certificate_of_compliance'] = [{ certificate_of_compliance_id: 'c1', activity_id: 'a1' }]; + lists['certification_on_chain'] = [{ certificate_of_compliance_id: 'c1' }]; + const b = await getTokenizationBacklog('token'); - expect(b.entries.find((e) => e.label === 'Credit batches')!.expected).toBe(0); + const activities = b.entries.find((e) => e.label === 'Activities')!; + expect(activities.expected).toBe(1); + expect(activities.pending).toEqual(['a1']); + expect(b.entries.find((e) => e.label === 'Certificates')!.pending).toEqual([]); }); it('covers all four token types', async () => { diff --git a/src/lib/chain/onChain.test.ts b/src/lib/chain/onChain.test.ts index aa5e53e..3cbd186 100644 --- a/src/lib/chain/onChain.test.ts +++ b/src/lib/chain/onChain.test.ts @@ -46,26 +46,29 @@ describe('getRecentChainEvents', () => { expect(e.tokenId).toBe(1); }); - it('resolves a credit batch to its activity page via the activity NFT id', async () => { - lists['activity_on_chain'] = [{ activity_id: 'activity_local_1', token_id: '1', block_number: '2' }]; + it('links a credit batch to its activity page via the activity_id it carries', async () => { lists['carbon_credit_batch_on_chain'] = [ { - batch_key: '1:permanent_carbon_removal', - credit_type: 'permanent_carbon_removal', + carbon_credit_batch_id: 'mv1:permanent_removal', + batch_name: 'Permanent Removals Carbon Credit Batch', + unit_type: 'Permanent Removal', token_id: '1', + activity_id: 'activity_local_1', activity_nft_id: '1', - certification_url: 'http://localhost:8080/obp/dynamic-entity/certificate_of_compliance/coc_local_1', + certificate_of_compliance_uri: + 'http://localhost:8080/obp/dynamic-entity/certificate_of_compliance/coc_local_1', block_number: '2' } ]; const batch = (await getRecentChainEvents('token')).find((e) => e.kind === 'Credit Batch')!; - expect(batch.label).toBe('permanent_carbon_removal'); + expect(batch.label).toBe('Permanent Removals Carbon Credit Batch'); expect(batch.href).toBe('/activities/activity_local_1'); + expect(batch.registryUrl).toContain('coc_local_1'); }); - it('leaves href null when the activity NFT id matches nothing', async () => { + it('leaves href null when a batch carries no activity_id', async () => { lists['carbon_credit_batch_on_chain'] = [ - { batch_key: '9:x', credit_type: 'x', token_id: '9', activity_nft_id: '9', block_number: '1' } + { carbon_credit_batch_id: 'mv9:x', unit_type: 'x', token_id: '9', block_number: '1' } ]; const [e] = await getRecentChainEvents('token'); expect(e.href).toBeNull(); diff --git a/src/lib/chain/onChain.ts b/src/lib/chain/onChain.ts index d956878..f1cb1a9 100644 --- a/src/lib/chain/onChain.ts +++ b/src/lib/chain/onChain.ts @@ -82,9 +82,8 @@ export async function getRecentChainEvents( accessToken: string, limit = 10 ): Promise { - // Fetched together because credit batches reference their activity by NFT - // token id, and turning that into a link needs the activity mirror to - // resolve the id back to a registry activity_id. + // Fetched together so the newest tokens across every mirror can be merged + // into one list. const [parcels, activities, certifications, batches] = await Promise.all([ listEntity(ENTITY_PARCEL_ON_CHAIN, accessToken).catch(() => []), listEntity(ENTITY_ACTIVITY_ON_CHAIN, accessToken).catch(() => []), @@ -92,27 +91,20 @@ export async function getRecentChainEvents( listEntity(ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, accessToken).catch(() => []) ]); - const activityIdByToken = new Map(); - for (const a of activities) { - const token = toNumber(a.token_id); - if (token !== null && a.activity_id) activityIdByToken.set(token, a.activity_id); - } - const events: ChainEvent[] = []; for (const b of batches) { - const activityId = activityIdByToken.get(toNumber(b.activity_nft_id) ?? -1) ?? null; events.push({ kind: 'Credit Batch', tokenId: toNumber(b.token_id), - label: b.credit_type || b.batch_key || '', + label: b.batch_name || b.unit_type || b.carbon_credit_batch_id || '', blockNumber: toNumber(b.block_number), txHash: b.tx_hash ?? null, ownerAddress: b.owner_address ?? null, // A batch has no page of its own, but it belongs to an activity that // does, which is the more useful destination anyway. - href: activityHref(activityId), - registryUrl: b.certification_url || b.activity_url || null + href: activityHref(b.activity_id), + registryUrl: b.certificate_of_compliance_uri || b.activity_url || null }); } @@ -120,12 +112,12 @@ export async function getRecentChainEvents( events.push({ kind: 'Certification', tokenId: toNumber(c.token_id), - label: c.certification_of_compliance_id ?? '', + label: c.certificate_of_compliance_id ?? '', blockNumber: toNumber(c.block_number), txHash: c.tx_hash ?? null, ownerAddress: c.owner_address ?? null, - href: activityHref(activityIdByToken.get(toNumber(c.activity_nft_id) ?? -1) ?? null), - registryUrl: c.certification_url || null + href: activityHref(c.activity_id), + registryUrl: c.certificate_of_compliance_uri || null }); } @@ -161,24 +153,15 @@ export async function getRecentChainEvents( return events.slice(0, limit); } -/** The four benefit fields a monitoring verification can carry, and the credit - * type each one mints as. Taken from the tokenizer's own CreditAmounts(): a - * batch is minted per field with a positive amount, so these names are the - * credit types that appear on chain. */ -const CREDIT_BENEFIT_FIELDS: Array<[field: string, creditType: string]> = [ - ['permanent_net_carbon_removal_benefit', 'permanent_net_carbon_removal_benefit'], - [ - 'carbon_farming_temporary_net_carbon_removal_benefit', - 'carbon_farming_temporary_net_carbon_removal_benefit' - ], - [ - 'carbon_farming_net_soil_emission_reduction_benefit', - 'carbon_farming_net_soil_emission_reduction_benefit' - ], - [ - 'carbon_storage_temporary_net_carbon_removal_benefit', - 'carbon_storage_temporary_net_carbon_removal_benefit' - ] +/** 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 + * positive amount, with id `:`. */ +const CREDIT_BENEFIT_FIELDS: Array<[field: string, suffix: string]> = [ + ['permanent_net_carbon_removal_benefit', 'permanent_removal'], + ['carbon_farming_temporary_net_carbon_removal_benefit', 'carbon_farming_sequestration'], + ['carbon_farming_net_soil_emission_reduction_benefit', 'soil_emission_reduction'], + ['carbon_storage_temporary_net_carbon_removal_benefit', 'carbon_storage_in_product'] ]; /** @@ -189,11 +172,13 @@ const CREDIT_BENEFIT_FIELDS: Array<[field: string, creditType: string]> = [ * * - **Parcels** from `parcel_owner_verification` with `status_code` of * `verified`. - * - **Activities** from `activity_verification` with the same status. + * - **Activities** from `activity_verification` with the same status, once + * the activity also has a `certificate_of_compliance`. * - **Certificates** have no further gate: a certificate of compliance is * itself the verification, so every one is expected on chain. * - **Credit batches** from `activity_monitoring_period_verification` with the - * same status, one batch per benefit field carrying a positive amount. + * same status, one batch per benefit field carrying a positive amount, keyed + * `:`. */ export async function getTokenizationBacklog(accessToken: string): Promise { const [ @@ -219,28 +204,23 @@ export async function getTokenizationBacklog(accessToken: string): Promise[], idField: string) => rows.filter((r) => r.status_code === 'verified').map((r) => r[idField] as string); - // A batch is keyed on chain by the activity's NFT token id, not its registry - // id, so the expected keys can only be built for activities already - // tokenized. That dependency is real: the tokenizer defers a batch until the - // ActivityNFT exists, so an untokenized activity blocks its batches too. - const activityTokenById = new Map(); - for (const a of activitiesOnChain) { - const token = toNumber(a.token_id); - if (token !== null && a.activity_id) activityTokenById.set(a.activity_id, token); - } + // The tokenizer mints an activity once it has a verified verification and a + // certificate of compliance. + const certifiedActivities = new Set(certificates.map((r) => r.activity_id as string)); + const readyActivities = verified(activityVerifs, 'activity_id').filter((id) => + certifiedActivities.has(id) + ); - const expectedBatchKeys: string[] = []; + // A batch is keyed on chain by its carbon_credit_batch_id, which the + // tokenizer derives from the verification id and the unit type. + const expectedBatchIds: string[] = []; for (const mv of monitoringVerifs) { if (mv.status_code !== 'verified') continue; - const activityId = mv.activity_id as string; - for (const [field, creditType] of CREDIT_BENEFIT_FIELDS) { + const verificationId = mv.activity_monitoring_period_verification_id as string; + if (!verificationId) continue; + for (const [field, suffix] of CREDIT_BENEFIT_FIELDS) { if ((toNumber(mv[field]) ?? 0) <= 0) continue; - const token = activityTokenById.get(activityId); - expectedBatchKeys.push( - token === undefined - ? `${activityId}:${creditType} (awaiting ActivityNFT)` - : `${token}:${creditType}` - ); + expectedBatchIds.push(`${verificationId}:${suffix}`); } } @@ -253,21 +233,21 @@ export async function getTokenizationBacklog(accessToken: string): Promise r.activity_id as string) ), diffIds( 'Certificates', 'Every certificate of compliance', certificates.map((r) => r.certificate_of_compliance_id as string), - certsOnChain.map((r) => r.certification_of_compliance_id as string) + certsOnChain.map((r) => r.certificate_of_compliance_id as string) ), diffIds( 'Credit batches', 'One per positive benefit amount on a verified monitoring period verification', - expectedBatchKeys, - batchesOnChain.map((r) => r.batch_key as string) + expectedBatchIds, + batchesOnChain.map((r) => r.carbon_credit_batch_id as string) ) ]); } diff --git a/src/lib/obp/glossary.test.ts b/src/lib/obp/glossary.test.ts new file mode 100644 index 0000000..e53ad0c --- /dev/null +++ b/src/lib/obp/glossary.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { pickGlossaryItems } from './glossary'; + +const item = (title: string, html: string) => ({ title, description: { markdown: '', html } }); + +describe('pickGlossaryItems', () => { + it('returns the asked-for items in the order asked for', () => { + expect( + pickGlossaryItems( + { + glossary_items: [ + item('OGCR Architecture', '

arch

'), + item('Other', '

other

'), + item('OGCR Smart Contracts', '

contracts

') + ] + }, + ['OGCR Smart Contracts', 'OGCR Architecture'] + ) + ).toEqual([ + { title: 'OGCR Smart Contracts', html: '

contracts

' }, + { title: 'OGCR Architecture', html: '

arch

' } + ]); + }); + + it('leaves out titles OBP has no item or no HTML for', () => { + expect( + pickGlossaryItems( + { glossary_items: [{ title: 'No HTML', description: { markdown: 'x' } }] }, + ['No HTML', 'Missing'] + ) + ).toEqual([]); + }); + + it('is empty for a body without a glossary_items list', () => { + expect(pickGlossaryItems({ code: 404 }, ['OGCR Smart Contracts'])).toEqual([]); + expect(pickGlossaryItems(null, ['OGCR Smart Contracts'])).toEqual([]); + }); +}); diff --git a/src/lib/obp/glossary.ts b/src/lib/obp/glossary.ts new file mode 100644 index 0000000..a90917b --- /dev/null +++ b/src/lib/obp/glossary.ts @@ -0,0 +1,58 @@ +import { env } from '$env/dynamic/public'; + +/** + * OBP glossary items, as OBP-API publishes them at GET /obp/v6.0.0/api/glossary. + * Public: no access token. + * + * OBP has no endpoint for a single item, so this fetches the whole glossary (about 1 MB) + * and picks the wanted items out by title. + */ +export const GLOSSARY_PATH = '/obp/v6.0.0/api/glossary'; + +export interface GlossaryItem { + title: string; + /** Rendered by OBP from the item's markdown. */ + html: string; +} + +/** Picks the items with these titles out of the endpoint's + * `{ glossary_items: [{ title, description: { markdown, html } }] }`, in the order asked + * for. A title OBP has no item (or no HTML) for is left out. */ +export function pickGlossaryItems(body: unknown, titles: string[]): GlossaryItem[] { + const entries = (body as { glossary_items?: unknown })?.glossary_items; + if (!Array.isArray(entries)) return []; + const byTitle = new Map(); + for (const entry of entries) { + const { title, description } = (entry ?? {}) as { + title?: unknown; + description?: { html?: unknown }; + }; + if (typeof title === 'string' && typeof description?.html === 'string') { + byTitle.set(title, description.html); + } + } + return titles.flatMap((title) => { + const html = byTitle.get(title); + return html ? [{ title, html }] : []; + }); +} + +/** + * Fetch the glossary items with these titles. Never throws: an OBP that is down just + * means no items, and the page says so. + * + * Pass the `fetch` from a SvelteKit `load` so the request participates in SSR. + */ +export async function getGlossaryItems( + titles: string[], + fetchFn: typeof fetch = fetch +): Promise { + const base = env.PUBLIC_OBP_BASE_URL?.replace(/\/$/, '') ?? ''; + try { + const response = await fetchFn(`${base}${GLOSSARY_PATH}`); + if (!response.ok) return []; + return pickGlossaryItems(await response.json(), titles); + } catch { + return []; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 752da76..06fd87b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -58,7 +58,7 @@ { href: data.portalUrl, icon: Globe, - title: 'Register', + title: 'Portal / Register', description: 'Register, get API keys and read the guides.' }, { diff --git a/src/routes/help/+page.server.ts b/src/routes/help/+page.server.ts new file mode 100644 index 0000000..f24d04a --- /dev/null +++ b/src/routes/help/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; +import { getGlossaryItems } from '$lib/obp/glossary'; + +/** The OBP glossary is the source of truth for how the tokens work: OGCR Smart Contracts + * is generated from the contracts' own documentation, and refers to OGCR Architecture for + * when each token is minted. */ +const HELP_GLOSSARY_TITLES = ['OGCR Smart Contracts', 'OGCR Architecture']; + +export const load: PageServerLoad = async ({ fetch }) => { + return { glossaryItems: await getGlossaryItems(HELP_GLOSSARY_TITLES, fetch) }; +}; diff --git a/src/routes/help/+page.svelte b/src/routes/help/+page.svelte index 9a26dab..7abb4df 100644 --- a/src/routes/help/+page.svelte +++ b/src/routes/help/+page.svelte @@ -1,17 +1,9 @@
@@ -25,181 +17,20 @@

- Carbon credits in OGCR are recorded on a blockchain. This page explains the model in plain - English — no prior blockchain knowledge assumed. The short version: a certificate proves where credits came from, and the credits live in a wallet that belongs - to that certificate. + How OGCR's tokens work, from the OBP glossary. The glossary is generated from the smart + contracts' own documentation, so it is always the authority on how the contracts behave.

- -
-

Two kinds of token

+ {#each data.glossaryItems as item (item.title)} + + +
{@html item.html}
+
+ {:else}

- Everything on the chain is one of two kinds. The difference matters, because they answer - different questions. + The glossary could not be loaded from OBP. Try again later.

-
-
-
- -

Certificates

-
-

- Each one is unique and one-of-a-kind — like a passport or a title deed. - It answers "what is this, and where did it come from?" Parcels, activities, - certifications and credit batches are all certificates. -

-

Technically: ERC-721, often called an NFT.

-
-
-
- -

Credits

-
-

- These are interchangeable units — like coins or shares. One is exactly - as good as another. They answer "how many?" A balance of 300 is just a number - attached to an address. -

-

Technically: ERC-20, the Carbon Credit token.

-
-
-
- - -
-
- -

The chain of records

-
-

- Credits are not created out of nowhere. Each one traces back through a chain of certificates, - every link recorded on-chain before the next can exist. -

-
-
- Parcel — a piece of land. -
-
-
- Activity — what is being done on that land, and over what period. -
-
-
- Certification — an independent body confirming the activity meets a - scheme's rules. -
-
-
- Credit batch — credits issued against that certified activity, for one - type of credit. -
-
-

- A batch certificate carries links to each supporting document, together with a - fingerprint (a hash) of its contents. Anyone can fetch a document and check - the fingerprint still matches — proving it has not been altered since the credits were issued. -

-
- - -
-
- -

Every batch has its own wallet

-
-

- This is the part that surprises most people. When a credit batch certificate is created, it - automatically gets its own wallet — a real address on the chain. Not a wallet - belonging to a person; a wallet belonging to the certificate. -

-

The credits are then placed in that wallet.

-
-

- Batch #1 — certificate for 300 credits of soil carbon, from activity - "Meadow restoration" -

-

- ↳ its wallet holds 300 Carbon Credit tokens -

-

- Batch #2 — certificate for 700 credits of a different type, same activity -

-

- ↳ its own separate wallet holds 700 Carbon Credit tokens -

-
-

- Two batches, two wallets, two balances that never mix. That separation is what keeps each - credit connected to the evidence behind it. -

-
- -

- Who controls the wallet? Whoever holds the certificate. The wallet has no - password or key of its own — it simply asks, every single time, "who owns my certificate - right now?" and takes instructions only from that person. -

-
-
- - -
-
- -

What happens when a batch is sold

-
-

- If Simon owns batch #1 and sells it to Bob, the credits themselves never move. All - that changes is who holds the certificate. -

-
-
-

Before

-
    -
  • Simon holds certificate #1
  • -
  • Batch #1's wallet holds 300 credits
  • -
  • Simon controls those 300 credits
  • -
-
-
-

After

-
    -
  • Bob holds certificate #1
  • -
  • Batch #1's wallet still holds the same 300 credits
  • -
  • Bob now controls those 300 credits
  • -
-
-
-

- One transaction transfers the proof and the quantity together. The buyer receives credits that - still point at the activity, the certification body and the scheme that produced them. -

-
- - -
-
- -

Credits can also be taken out of a batch

-
-

- The holder of a batch can move credits out of its wallet and into an ordinary personal wallet. - This is allowed, but it has a consequence worth understanding. -

-

- Once credits sit in a personal wallet they are pooled. Credits from different - batches mix together into a single balance, and at that point the chain can no longer say - which batch any individual credit came from — in the same way that coins from two different - pockets, once in the same jar, can no longer be told apart. -

-

- So credits exist in two states, and the Chain page counts both: inside a batch, where full provenance is intact, and in a wallet, where only the quantity - remains. -

-
+ {/each}
@@ -316,3 +147,82 @@

+ +