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
66 changes: 66 additions & 0 deletions api/src/common/filters/structured-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

/**
* Structured error response shape for API clients.
*
* Additive filter (not yet wired into main.ts / app.module.ts). Intended to
* replace NestJS's default unstructured exception body so the frontend can
* distinguish error kinds (e.g. "Invalid tonnes") and show localized,
* context-specific messages instead of a generic 400 handler.
*
* To adopt: register globally, e.g. in main.ts:
* app.useGlobalFilters(new StructuredExceptionFilter());
*/
export interface StructuredErrorResponse {
code: string;
message: string;
details?: Record<string, unknown>;
}

@Catch(HttpException)
export class StructuredExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const body = exception.getResponse();

const { code, message, details } = this.normalize(status, body);

response.status(status).json({ code, message, details });
}

private normalize(
status: number,
body: string | object,
): StructuredErrorResponse {
const code = HttpStatus[status] ?? 'ERROR';

if (typeof body === 'string') {
return { code, message: body };
}

const obj = body as Record<string, unknown>;
const message = Array.isArray(obj.message)
? obj.message.join('; ')
: ((obj.message as string) ?? 'An error occurred');

const details = { ...obj };
delete details.message;
delete details.statusCode;
delete details.error;

return {
code: typeof obj.error === 'string' ? obj.error : code,
message,
details: Object.keys(details).length ? details : undefined,
};
}
}
55 changes: 55 additions & 0 deletions contracts/credit_registry/src/approvals_bitmap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Bitmap-based verifier approval tracking (additive, not yet wired in).
//!
//! DataKey::CreditApprovals(credit_id) currently stores a `Vec<Address>` of every
//! verifier who approved a credit. With 50 registered verifiers this Vec can grow
//! to ~1.6KB per credit, and membership checks in `approve_and_mint` are O(n).
//!
//! This module provides a drop-in replacement storage shape: a `u64` bitmap where
//! bit `i` represents the verifier at index `i` in the registry's verifier list
//! (see `storage::get_verifiers`). 50 verifiers fit in 8 bytes instead of 1.6KB,
//! and approval checks/inserts become O(1) bit operations.
//!
//! To adopt: replace `DataKey::CreditApprovals(BytesN<32>) -> Vec<Address>` reads/
//! writes in `approve_and_mint` with the helpers below, resolving each verifier's
//! index via `get_verifiers(env).iter().position(...)`.

use soroban_sdk::{Address, Env, Vec};

/// Returns true if the verifier at `index` has already approved.
pub fn has_approved(bitmap: u64, index: u32) -> bool {
debug_assert!(index < 64);
(bitmap & (1u64 << index)) != 0
}

/// Returns a new bitmap with the verifier at `index` marked as approved.
pub fn set_approved(bitmap: u64, index: u32) -> u64 {
debug_assert!(index < 64);
bitmap | (1u64 << index)
}

/// Number of verifiers who have approved so far.
pub fn approval_count(bitmap: u64) -> u32 {
bitmap.count_ones()
}

/// Finds the index of `verifier` within the registry's verifier list.
pub fn verifier_index(env: &Env, verifiers: &Vec<Address>, verifier: &Address) -> Option<u32> {
let _ = env;
verifiers.iter().position(|v| v == *verifier).map(|i| i as u32)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn bitmap_set_and_check() {
let bm = 0u64;
assert!(!has_approved(bm, 3));
let bm = set_approved(bm, 3);
assert!(has_approved(bm, 3));
assert_eq!(approval_count(bm), 1);
let bm = set_approved(bm, 10);
assert_eq!(approval_count(bm), 2);
}
}
43 changes: 43 additions & 0 deletions frontend/src/app/marketplace/loading-skeleton.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';

/**
* Standalone loading skeleton for the marketplace credit list.
*
* Additive component (not yet wired into marketplace.component.ts). Intended
* usage: show `<app-marketplace-loading-skeleton>` while the GET
* /api/v1/credits request is in flight, so the first paint isn't a blank
* white screen during the ~1-2s fetch.
*/
@Component({
selector: 'app-marketplace-loading-skeleton',
standalone: true,
imports: [CommonModule],
template: `
<div class="skeleton-list" aria-busy="true" aria-live="polite">
<div class="skeleton-card" *ngFor="let i of rows"></div>
</div>
`,
styles: [`

Check failure on line 21 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `⏎····`
.skeleton-list { display: flex; flex-direction: column; gap: 12px; }

Check failure on line 22 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Replace `····.skeleton-list·{·display:·flex;·flex-direction:·column;·gap:·12px;` with `······.skeleton-list·{⏎········display:·flex;⏎········flex-direction:·column;⏎········gap:·12px;⏎·····`
.skeleton-card {

Check failure on line 23 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
height: 88px;

Check failure on line 24 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
border-radius: 8px;

Check failure on line 25 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
background: linear-gradient(90deg, #e8e8e8 25%, #f2f2f2 37%, #e8e8e8 63%);

Check failure on line 26 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
background-size: 400% 100%;

Check failure on line 27 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
animation: skeleton-shimmer 1.4s ease infinite;

Check failure on line 28 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
}

Check failure on line 29 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
@keyframes skeleton-shimmer {

Check failure on line 30 in frontend/src/app/marketplace/loading-skeleton.component.ts

View workflow job for this annotation

GitHub Actions / Frontend SPA (Angular)

Insert `··`
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
`],
})
export class MarketplaceLoadingSkeletonComponent {
/** Number of placeholder rows to render while credits are loading. */
@Input() count = 6;

get rows(): number[] {
return Array.from({ length: this.count }, (_, i) => i);
}
}
49 changes: 49 additions & 0 deletions scripts/smoke-test-contracts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/bin/bash
# Post-deployment contract-state smoke test (additive, standalone).
#
# Complements scripts/smoke-test.sh by verifying core contract wiring via the
# Soroban/Stellar CLI instead of HTTP endpoints: admin initialization on each
# contract, and that credit_registry is wired to the correct retirement
# contract address. Catches deployment regressions such as wrong contract
# wiring or missing admin initialization.
#
# Usage: scripts/smoke-test-contracts.sh <contract-ids.json> <network>

set -euo pipefail

CONTRACTS_FILE="${1:?Usage: $0 <contract-ids.json> <network>}"
NETWORK="${2:-testnet}"

log() { echo " [contract-smoke] $*"; }
pass() { echo " ✅ $*"; }
fail() { echo " ❌ $*" >&2; exit 1; }

command -v stellar >/dev/null 2>&1 || fail "stellar CLI not found on PATH"
[[ -f "$CONTRACTS_FILE" ]] || fail "Contracts file not found: $CONTRACTS_FILE"

CREDIT_REGISTRY_ID=$(jq -r '.credit_registry' "$CONTRACTS_FILE")
RETIREMENT_ID=$(jq -r '.retirement' "$CONTRACTS_FILE")
MARKETPLACE_ID=$(jq -r '.marketplace' "$CONTRACTS_FILE")
VERIFIER_REGISTRY_ID=$(jq -r '.verifier_registry // .credit_registry' "$CONTRACTS_FILE")

invoke() {
local contract_id="$1"; shift
stellar contract invoke --id "$contract_id" --network "$NETWORK" -- "$@"
}

log "Checking admin is initialized on each contract..."
for name_id in "credit_registry:$CREDIT_REGISTRY_ID" "retirement:$RETIREMENT_ID" "marketplace:$MARKETPLACE_ID"; do
name="${name_id%%:*}"
id="${name_id##*:}"
[[ "$id" != "null" && -n "$id" ]] || fail "$name contract id missing from $CONTRACTS_FILE"
ADMIN=$(invoke "$id" get_admin 2>/dev/null || true)
[[ -n "$ADMIN" && "$ADMIN" != "null" ]] || fail "$name: admin not initialized"
pass "$name admin initialized ($ADMIN)"
done

log "Checking credit_registry is wired to the correct retirement contract..."
WIRED_RETIREMENT=$(invoke "$CREDIT_REGISTRY_ID" get_retirement_contract 2>/dev/null || true)
[[ "$WIRED_RETIREMENT" == *"$RETIREMENT_ID"* ]] || fail "credit_registry.retirement_contract mismatch: got $WIRED_RETIREMENT, expected $RETIREMENT_ID"
pass "credit_registry -> retirement wiring correct"

pass "All contract-state smoke checks passed."
Loading