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
24 changes: 23 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/app/api/attestations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions src/app/api/commitments/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/app/api/commitments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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);
Expand Down
75 changes: 34 additions & 41 deletions src/lib/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,19 @@ export type ContractsConfig = Record<
Record<string, ContractEntry | undefined>
>;

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<string, string | undefined>)[k],
);
if (!anySet) return null;

const env = getValidatedEnv() as Record<string, string | undefined>;

const v1: Record<string, ContractEntry | undefined> = {};
for (const [key, envName] of Object.entries(LEGACY_ENV_MAPPING)) {
const addr = (env as Record<string, string | undefined>)[envName] || "";

const mapping: Record<string, string[]> = {
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 };
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -149,13 +151,15 @@ 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;
networkPassphrase: string;
contractAddresses: ContractAddresses;
environment: Environment;
chainWritesEnabled: boolean;
activeVersion: string;
}

/**
Expand Down Expand Up @@ -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,
};
}

Expand Down
53 changes: 45 additions & 8 deletions src/lib/backend/services/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -74,6 +76,7 @@ export interface RecordAttestationOnChainResult {
feeEarned: string;
recordedAt: string;
txHash?: string;
contractVersion?: string;
}

export interface SettleCommitmentOnChainParams {
Expand All @@ -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;
Expand Down Expand Up @@ -317,7 +322,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);

Expand Down Expand Up @@ -345,12 +353,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 {
Expand All @@ -365,24 +375,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);
Expand All @@ -406,15 +423,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(
Expand Down Expand Up @@ -605,7 +626,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();
Expand Down Expand Up @@ -651,7 +676,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) {
Expand Down Expand Up @@ -691,7 +719,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
Expand Down Expand Up @@ -781,7 +812,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();
Expand Down Expand Up @@ -869,6 +904,7 @@ export async function settleCommitmentOnChain(
settlementAmount,
finalStatus,
txHash: invocation.txHash,
contractVersion: invocation.version,
reference: invocation.txHash
? undefined
: "TODO_CHAIN_CALL_SETTLE_COMMITMENT",
Expand Down Expand Up @@ -945,6 +981,7 @@ export async function earlyExitCommitmentOnChain(
penaltyAmount,
finalStatus,
txHash: invocation.txHash,
contractVersion: invocation.version,
reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT`
};
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/schemas/apiContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading