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
20 changes: 10 additions & 10 deletions chain_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` (`<monitoring verification id>:<unit>`) | 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
33 changes: 17 additions & 16 deletions src/lib/chain/backlogRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
19 changes: 11 additions & 8 deletions src/lib/chain/onChain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
98 changes: 39 additions & 59 deletions src/lib/chain/onChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,50 +82,42 @@ export async function getRecentChainEvents(
accessToken: string,
limit = 10
): Promise<ChainEvent[]> {
// 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(() => []),
listEntity(ENTITY_CERTIFICATION_ON_CHAIN, accessToken).catch(() => []),
listEntity(ENTITY_CARBON_CREDIT_BATCH_ON_CHAIN, accessToken).catch(() => [])
]);

const activityIdByToken = new Map<number, string>();
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
});
}

for (const c of certifications) {
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
});
}

Expand Down Expand Up @@ -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 `<activity_monitoring_period_verification_id>:<suffix>`. */
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']
];

/**
Expand All @@ -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
* `<verification id>:<unit suffix>`.
*/
export async function getTokenizationBacklog(accessToken: string): Promise<Backlog> {
const [
Expand All @@ -219,28 +204,23 @@ export async function getTokenizationBacklog(accessToken: string): Promise<Backl
const verified = (rows: Record<string, any>[], 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<string, number>();
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}`);
}
}

Expand All @@ -253,21 +233,21 @@ export async function getTokenizationBacklog(accessToken: string): Promise<Backl
),
diffIds(
'Activities',
'Activities with a verified activity verification',
verified(activityVerifs, 'activity_id'),
'Activities with a verified activity verification and a certificate of compliance',
readyActivities,
activitiesOnChain.map((r) => 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)
)
]);
}
38 changes: 38 additions & 0 deletions src/lib/obp/glossary.test.ts
Original file line number Diff line number Diff line change
@@ -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', '<p>arch</p>'),
item('Other', '<p>other</p>'),
item('OGCR Smart Contracts', '<p>contracts</p>')
]
},
['OGCR Smart Contracts', 'OGCR Architecture']
)
).toEqual([
{ title: 'OGCR Smart Contracts', html: '<p>contracts</p>' },
{ title: 'OGCR Architecture', html: '<p>arch</p>' }
]);
});

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([]);
});
});
Loading
Loading