From 327a6958892fdeae5fba95f8bf5d4922b81c3a71 Mon Sep 17 00:00:00 2001 From: webdevayo Date: Wed, 27 May 2026 09:41:42 +0100 Subject: [PATCH] Add testnet environment configuration and diagnostics --- docs/config.md | 24 +++- src/app/api/attestations/route.ts | 1 + .../api/commitments/[id]/early-exit/route.ts | 5 +- src/app/api/commitments/[id]/route.ts | 1 + src/app/api/commitments/[id]/settle/route.ts | 1 + src/app/api/commitments/route.ts | 1 + src/lib/backend/config.ts | 75 ++++++------ src/lib/backend/services/contracts.ts | 59 +++++++-- src/lib/schemas/apiContracts.ts | 3 + tests/lib/backend/config_versioning.test.ts | 79 ++++++++++++ .../lib/backend/contracts_versioning.test.ts | 115 ++++++++++++++++++ 11 files changed, 311 insertions(+), 53 deletions(-) create mode 100644 tests/lib/backend/config_versioning.test.ts create mode 100644 tests/lib/backend/contracts_versioning.test.ts diff --git a/docs/config.md b/docs/config.md index 601dde04..50e8d603 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,11 +36,33 @@ How to switch versions safely 2. Set `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` to the desired version (e.g., `v2`). 3. Restart the application to pick up new environment variables. +Fallback behavior +- If `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` is not set, the application defaults to `v1`. +- If `NEXT_PUBLIC_CONTRACTS_JSON` is not set, the application falls back to parsing legacy environment variables (`NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT`, etc.) and treating them as `v1` contracts. +- If a requested contract entry or key is missing in a version, the application will throw an error during contract resolution. + +Invalid version handling +- If `NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION` points to a version not defined in `NEXT_PUBLIC_CONTRACTS_JSON`, the application will throw an error: "Active contract version 'X' not found". +- Invalid JSON in `NEXT_PUBLIC_CONTRACTS_JSON` will cause a parse error at startup; check JSON syntax and proper escaping. +- Incomplete contract entries (missing `address` field) in a version will throw an error when that contract is accessed. + Example `.env` entries ``` NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION=v2 -NEXT_PUBLIC_CONTRACTS_JSON={"v1":{"commitmentCore":{"address":"0xold"}},"v2":{"commitmentCore":{"address":"0xnew"}}} + +NEXT_PUBLIC_CONTRACTS_JSON={ + "v1": { + "commitmentCore": { + "address": "0xv1core" + } + }, + "v2": { + "commitmentCore": { + "address": "0xv2core" + } + } +} ``` Common misconfiguration errors and fixes diff --git a/src/app/api/attestations/route.ts b/src/app/api/attestations/route.ts index 4fa6a484..deeea975 100644 --- a/src/app/api/attestations/route.ts +++ b/src/app/api/attestations/route.ts @@ -149,6 +149,7 @@ export const POST = withApiHandler(async (req: NextRequest, _context, correlatio violation: result.violation, feeEarned: result.feeEarned, recordedAt: result.recordedAt, + contractVersion: result.contractVersion, }, txReference: result.txHash ?? null, }, diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 0e011321..dc8c63fe 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -30,8 +30,11 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat return ok( { - message: `Stub early-exit endpoint for commitment ${params.id}`, commitmentId: params.id, + txHash: '', + reference: '', + settledAt: new Date().toISOString(), + contractVersion: '', }, undefined, 200, diff --git a/src/app/api/commitments/[id]/route.ts b/src/app/api/commitments/[id]/route.ts index 477eb612..889dc357 100644 --- a/src/app/api/commitments/[id]/route.ts +++ b/src/app/api/commitments/[id]/route.ts @@ -67,6 +67,7 @@ export const GET = withApiHandler(async (_req: NextRequest, context, correlation maxLossPercent: commitment.rules?.maxLossPercent ?? null, tokenId: commitment.tokenId ?? null, nftMetadataLink: getNftMetadataLink(String(commitment.id ?? commitment.commitmentId)), + contractVersion: commitment.contractVersion, }, undefined, 200, diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index 83bd0939..09aaee41 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -83,6 +83,7 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat txHash: settlementResult.txHash, reference: settlementResult.reference, settledAt: new Date().toISOString(), + contractVersion: settlementResult.contractVersion, }, undefined, 200, diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 978109d1..1f7bf050 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -69,6 +69,7 @@ export const GET = withApiHandler(async (req: NextRequest, _context, correlation violationCount: c.violationCount, createdAt: c.createdAt, expiresAt: c.expiresAt, + contractVersion: c.contractVersion, })); if (status) mapped = mapped.filter((c) => c.status === status); diff --git a/src/lib/backend/config.ts b/src/lib/backend/config.ts index 41b9c2ef..735489f1 100644 --- a/src/lib/backend/config.ts +++ b/src/lib/backend/config.ts @@ -14,22 +14,19 @@ export type ContractsConfig = Record< Record >; -const LEGACY_ENV_MAPPING = { - commitmentNFT: "NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT", - commitmentCore: "NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT", - attestationEngine: "NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT", -}; - function buildFromLegacyEnv(): ContractsConfig | null { - const env = getValidatedEnv(); - const anySet = Object.values(LEGACY_ENV_MAPPING).some( - (k) => !!(env as Record)[k], - ); - if (!anySet) return null; - + const env = getValidatedEnv() as Record; + const v1: Record = {}; - for (const [key, envName] of Object.entries(LEGACY_ENV_MAPPING)) { - const addr = (env as Record)[envName] || ""; + + const mapping: Record = { + commitmentNFT: ["COMMITMENT_NFT_CONTRACT", "NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT"], + commitmentCore: ["COMMITMENT_CORE_CONTRACT", "NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT"], + attestationEngine: ["ATTESTATION_ENGINE_CONTRACT", "NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT"], + }; + + for (const [key, envNames] of Object.entries(mapping)) { + const addr = env[envNames[0]] || env[envNames[1]] || ""; if (addr) v1[key] = { address: addr }; } @@ -77,6 +74,11 @@ export function loadContractsConfig(): ContractsConfig { return cachedConfig; } +/** Clears the module-level config cache. For tests only. */ +export function _resetEnvCache(): void { + cachedConfig = null; +} + export function getActiveContractVersion(): string { const env = getValidatedEnv(); return ( @@ -149,6 +151,7 @@ export type Environment = "development" | "preview" | "production"; * @property contractAddresses - Addresses of deployed Soroban smart contracts * @property environment - Current environment (development | preview | production) * @property chainWritesEnabled - Whether on-chain write operations are enabled (env: COMMITLABS_ENABLE_CHAIN_WRITES) + * @property activeVersion - The active version of the contracts being used */ export interface BackendConfig { sorobanRpcUrl: string; @@ -156,6 +159,7 @@ export interface BackendConfig { contractAddresses: ContractAddresses; environment: Environment; chainWritesEnabled: boolean; + activeVersion: string; } /** @@ -262,49 +266,38 @@ export function getBackendConfig(): BackendConfig { env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - const commitmentNFT = - env.COMMITMENT_NFT_CONTRACT ?? - env.NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT ?? - ""; - - const commitmentCore = - env.COMMITMENT_CORE_CONTRACT ?? - env.NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT ?? - ""; + // Resolve contract addresses via versioned config + const activeVersion = getActiveContractVersion(); + const contracts = getActiveContracts(); - const attestationEngine = - env.ATTESTATION_ENGINE_CONTRACT ?? - env.NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT ?? - ""; + const contractAddresses: ContractAddresses = { + commitmentNFT: contracts.commitmentNFT?.address || "", + commitmentCore: contracts.commitmentCore?.address || "", + attestationEngine: contracts.attestationEngine?.address || "", + }; if (!isTestEnvironment()) { - if (!commitmentNFT) + if (!contractAddresses.commitmentNFT) throw new Error( - "Missing required configuration: commitmentNFT. " + - "Set COMMITMENT_NFT_CONTRACT or NEXT_PUBLIC_COMMITMENT_NFT_CONTRACT", + `Missing required configuration: commitmentNFT in version "${activeVersion}"`, ); - if (!commitmentCore) + if (!contractAddresses.commitmentCore) throw new Error( - "Missing required configuration: commitmentCore. " + - "Set COMMITMENT_CORE_CONTRACT or NEXT_PUBLIC_COMMITMENT_CORE_CONTRACT", + `Missing required configuration: commitmentCore in version "${activeVersion}"`, ); - if (!attestationEngine) + if (!contractAddresses.attestationEngine) throw new Error( - "Missing required configuration: attestationEngine. " + - "Set ATTESTATION_ENGINE_CONTRACT or NEXT_PUBLIC_ATTESTATION_ENGINE_CONTRACT", + `Missing required configuration: attestationEngine in version "${activeVersion}"`, ); } return { sorobanRpcUrl, networkPassphrase, - contractAddresses: { - commitmentNFT, - commitmentCore, - attestationEngine, - }, + contractAddresses, environment: getEnvironment(), chainWritesEnabled: env.COMMITLABS_ENABLE_CHAIN_WRITES === "true", + activeVersion, }; } diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index e85d6af1..276ffe46 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -48,12 +48,14 @@ export interface ChainCommitment { violationCount: number; createdAt?: string; expiresAt?: string; + contractVersion?: string; } export interface CreateCommitmentOnChainResult { commitmentId: string; commitment: ChainCommitment; txHash?: string; + contractVersion?: string; } export interface RecordAttestationOnChainParams { @@ -74,6 +76,7 @@ export interface RecordAttestationOnChainResult { feeEarned: string; recordedAt: string; txHash?: string; + contractVersion?: string; } export interface SettleCommitmentOnChainParams { @@ -86,12 +89,14 @@ export interface SettleCommitmentOnChainResult { txHash?: string; reference?: string; finalStatus: string; + contractVersion?: string; } type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; txHash?: string; + version: string; } const ANALYTICS_SCALE = 100; @@ -262,7 +267,10 @@ function normalizeContractError( }); } -function parseChainCommitment(value: unknown): ChainCommitment { +function parseChainCommitment( + value: unknown, + contractVersion?: string, +): ChainCommitment { const raw = asRecord(value); const id = asString(raw.id ?? raw.commitmentId); @@ -290,12 +298,14 @@ function parseChainCommitment(value: unknown): ChainCommitment { violationCount: asNumber(raw.violationCount ?? raw.violation_count), createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, + contractVersion, }; } function parseCreateCommitmentResult( value: unknown, txHash?: string, + contractVersion?: string, ): CreateCommitmentOnChainResult { if (typeof value === "string") { return { @@ -310,24 +320,31 @@ function parseCreateCommitmentResult( currentValue: "0", feeEarned: "0", violationCount: 0, + contractVersion, }, txHash, + contractVersion, }; } const raw = asRecord(value); - const parsedCommitment = parseChainCommitment(raw.commitment ?? raw); + const parsedCommitment = parseChainCommitment( + raw.commitment ?? raw, + contractVersion, + ); return { commitmentId: parsedCommitment.id, commitment: parsedCommitment, txHash: asString(raw.txHash) || txHash, + contractVersion, }; } function parseAttestationResult( value: unknown, txHash?: string, + contractVersion?: string, ): RecordAttestationOnChainResult { const raw = asRecord(value); const attestationId = asString(raw.attestationId ?? raw.id); @@ -351,15 +368,19 @@ function parseAttestationResult( recordedAt: asString(raw.recordedAt ?? raw.recorded_at) || new Date().toISOString(), txHash: asString(raw.txHash) || txHash, + contractVersion, }; } -function parseCommitmentList(value: unknown): ChainCommitment[] { +function parseCommitmentList( + value: unknown, + contractVersion?: string, +): ChainCommitment[] { if (!Array.isArray(value)) { return []; } - return value.map((item) => parseChainCommitment(item)); + return value.map((item) => parseChainCommitment(item, contractVersion)); } async function waitForTransactionResult( @@ -420,6 +441,7 @@ async function invokeContractMethod( }); } + const config = getBackendConfig(); const server = getSorobanServer(); const contract = new Contract(contractId); const account = @@ -433,7 +455,7 @@ async function invokeContractMethod( const tx = new TransactionBuilder(account, { fee: String(BASE_FEE), - networkPassphrase: getNetworkPassphrase(), + networkPassphrase: config.networkPassphrase, }) .addOperation(operation) .setTimeout(30) @@ -452,6 +474,7 @@ async function invokeContractMethod( if (mode === "read") { return { value: simulation.result ? scValToNative(simulation.result.retval) : null, + version: config.activeVersion, }; } @@ -471,7 +494,7 @@ async function invokeContractMethod( const txHash = sendResult.hash; const onChainValue = await waitForTransactionResult(server, txHash); - return { value: onChainValue, txHash }; + return { value: onChainValue, txHash, version: config.activeVersion }; } function validateOwnerAddress(ownerAddress: string): void { @@ -510,7 +533,11 @@ export async function createCommitmentOnChain( void cache.delete(CacheKey.userCommitments(params.ownerAddress)); - return parseCreateCommitmentResult(invocation.value, invocation.txHash); + return parseCreateCommitmentResult( + invocation.value, + invocation.txHash, + invocation.version, + ); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -556,7 +583,10 @@ export async function getCommitmentFromChain( const countersAdapter = getCountersAdapter(); void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - const commitment = parseChainCommitment(invocation.value); + const commitment = parseChainCommitment( + invocation.value, + invocation.version, + ); await cache.set(cacheKey, commitment, CacheTTL.COMMITMENT_DETAIL); return commitment; } catch (error) { @@ -596,7 +626,10 @@ export async function getUserCommitmentsFromChain( [ownerAddress], "read", ); - const commitments = parseCommitmentList(directResult.value); + const commitments = parseCommitmentList( + directResult.value, + directResult.version, + ); if (commitments.length > 0) { await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); // Increment successful actions counter on successful chain read @@ -686,7 +719,11 @@ export async function recordAttestationOnChain( ); } - return parseAttestationResult(invocation.value, invocation.txHash); + return parseAttestationResult( + invocation.value, + invocation.txHash, + invocation.version, + ); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -774,6 +811,7 @@ export async function settleCommitmentOnChain( settlementAmount, finalStatus, txHash: invocation.txHash, + contractVersion: invocation.version, reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_SETTLE_COMMITMENT", @@ -850,6 +888,7 @@ export async function earlyExitCommitmentOnChain( penaltyAmount, finalStatus, txHash: invocation.txHash, + contractVersion: invocation.version, reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` }; } catch (error) { diff --git a/src/lib/schemas/apiContracts.ts b/src/lib/schemas/apiContracts.ts index 45d3946b..aaa94378 100644 --- a/src/lib/schemas/apiContracts.ts +++ b/src/lib/schemas/apiContracts.ts @@ -40,6 +40,7 @@ export const CommitmentItemSchema = z.object({ violationCount: z.number().optional(), createdAt: z.string(), expiresAt: z.string(), + contractVersion: z.string().optional(), }); export const CommitmentsListResponseSchema = OkBodySchema( @@ -66,6 +67,7 @@ export const CommitmentDetailSchema = z.object({ maxLossPercent: z.number().nullable(), tokenId: z.string().optional(), nftMetadataLink: z.string().optional(), + contractVersion: z.string().optional(), }); export const CommitmentDetailResponseSchema = OkBodySchema(CommitmentDetailSchema); @@ -96,6 +98,7 @@ export const AttestationSummarySchema = z.object({ violation: z.boolean(), feeEarned: z.string().optional(), recordedAt: z.string(), + contractVersion: z.string().optional(), }); export const AttestationPostResponseSchema = OkBodySchema( diff --git a/tests/lib/backend/config_versioning.test.ts b/tests/lib/backend/config_versioning.test.ts new file mode 100644 index 00000000..bdf884b9 --- /dev/null +++ b/tests/lib/backend/config_versioning.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getBackendConfig, _resetEnvCache } from '@/lib/backend/config'; +import { getValidatedEnv } from '@/lib/backend/env'; + +// Mock env.ts to control environment variables +vi.mock('@/lib/backend/env', async (importOriginal) => { + const actual = await importOriginal(); + let mockEnv: any = {}; + return { + ...actual, + getValidatedEnv: vi.fn(() => mockEnv), + _setMockEnv: (env: any) => { mockEnv = env; }, + }; +}); + +import { _setMockEnv } from '@/lib/backend/env'; + +describe('Contract Versioning Configuration', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetEnvCache(); + }); + + it('falls back to legacy env vars when no JSON config is provided', () => { + _setMockEnv({ + NODE_ENV: 'test', + COMMITMENT_CORE_CONTRACT: '0xlegacy', + COMMITMENT_NFT_CONTRACT: '0xnft', + ATTESTATION_ENGINE_CONTRACT: '0xattest', + }); + + const config = getBackendConfig(); + expect(config.contractAddresses.commitmentCore).toBe('0xlegacy'); + expect(config.contractAddresses.commitmentNFT).toBe('0xnft'); + expect(config.contractAddresses.attestationEngine).toBe('0xattest'); + }); + + it('uses versioned config from JSON when provided', () => { + const contractsJson = JSON.stringify({ + v1: { + commitmentCore: { address: '0xv1core' }, + commitmentNFT: { address: '0xv1nft' }, + attestationEngine: { address: '0xv1attest' }, + }, + v2: { + commitmentCore: { address: '0xv2core' }, + commitmentNFT: { address: '0xv2nft' }, + attestationEngine: { address: '0xv2attest' }, + } + }); + + _setMockEnv({ + NODE_ENV: 'test', + NEXT_PUBLIC_CONTRACTS_JSON: contractsJson, + NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION: 'v2', + }); + + const config = getBackendConfig(); + // This is expected to FAIL currently because getBackendConfig doesn't use versioned logic + expect(config.contractAddresses.commitmentCore).toBe('0xv2core'); + expect(config.activeVersion).toBe('v2'); + }); + + it('throws error when active version is missing from JSON', () => { + const contractsJson = JSON.stringify({ + v1: { + commitmentCore: { address: '0xv1core' }, + } + }); + + _setMockEnv({ + NODE_ENV: 'test', + NEXT_PUBLIC_CONTRACTS_JSON: contractsJson, + NEXT_PUBLIC_ACTIVE_CONTRACT_VERSION: 'v99', + }); + + expect(() => getBackendConfig()).toThrow(/Active contract version "v99" not found/); + }); +}); diff --git a/tests/lib/backend/contracts_versioning.test.ts b/tests/lib/backend/contracts_versioning.test.ts new file mode 100644 index 00000000..9ebc894c --- /dev/null +++ b/tests/lib/backend/contracts_versioning.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getCommitmentFromChain } from '@/lib/backend/services/contracts'; +import { getBackendConfig, _resetEnvCache } from '@/lib/backend/config'; + +// Mock config.ts +vi.mock('@/lib/backend/config', async (importOriginal) => { + const actual = await importOriginal(); + let mockVersion = 'v1'; + return { + ...actual, + getBackendConfig: vi.fn(() => ({ + sorobanRpcUrl: 'http://localhost', + networkPassphrase: 'Test', + contractAddresses: { + commitmentCore: 'core', + commitmentNFT: 'nft', + attestationEngine: 'attest', + }, + activeVersion: mockVersion, + })), + _setMockVersion: (v: string) => { mockVersion = v; }, + }; +}); + +import { _setMockVersion } from '@/lib/backend/config'; + +// Mock counters and logger to avoid ioredis or other missing deps +vi.mock('@/lib/backend/counters/provider', () => ({ + getCountersAdapter: vi.fn(() => ({ + incrementSuccessfulActions: vi.fn(), + incrementChainFailures: vi.fn(), + })), +})); + +vi.mock('@/lib/backend/logger', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +vi.mock('@/lib/backend/cache/factory', () => ({ + cache: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, +})); + +// Mock Stellar SDK and other dependencies used by contracts.ts +vi.mock('@stellar/stellar-sdk', () => ({ + Contract: vi.fn().mockImplementation(function() { + return { + call: vi.fn().mockReturnValue({}), + }; + }), + Account: vi.fn().mockImplementation(function() { + return {}; + }), + TransactionBuilder: vi.fn().mockImplementation(function() { + return { + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({}), + }; + }), + nativeToScVal: vi.fn(), + scValToNative: vi.fn(), + Address: vi.fn().mockImplementation(function() { + return { + toScVal: vi.fn(), + }; + }), + BASE_FEE: '100', + SorobanRpc: { + Server: vi.fn().mockImplementation(function() { + return { + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: {} } + }), + }; + }), + Api: { + isSimulationError: vi.fn().mockReturnValue(false), + } + } +})); + +// Mock scValToNative to return a mock commitment +import { scValToNative } from '@stellar/stellar-sdk'; + +describe('Contracts Service Versioning', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.SOROBAN_SOURCE_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + }); + + it('includes the active version in getCommitmentFromChain response', async () => { + _setMockVersion('v2'); + + (scValToNative as any).mockReturnValue({ + id: 'c1', + ownerAddress: 'addr1', + asset: 'USDC', + amount: '100', + status: 'ACTIVE', + complianceScore: 100, + currentValue: '100', + feeEarned: '0', + violationCount: 0, + }); + + const result = await getCommitmentFromChain('c1'); + expect(result.id).toBe('c1'); + expect(result.contractVersion).toBe('v2'); + }); +});