From ddaf479e595bcfe8c3fe9b42824c887966936519 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Fri, 24 Jul 2026 07:04:11 +0100 Subject: [PATCH 1/6] feat(access-logs): create AccessLogsModule, controller endpoint, and logger middleware (#894) --- .../access-logs.controller.spec.ts | 53 +++++++++++++++++++ .../src/access-logs/access-logs.controller.ts | 27 ++++++++++ backend/src/access-logs/access-logs.module.ts | 13 +++++ .../access-logs/dto/create-access-log.dto.ts | 18 ++++++- .../access-logs/entities/access-log.entity.ts | 3 ++ backend/src/app.module.ts | 2 + .../common/middleware/logger.middleware.ts | 27 ++++++++-- 7 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 backend/src/access-logs/access-logs.controller.spec.ts create mode 100644 backend/src/access-logs/access-logs.controller.ts create mode 100644 backend/src/access-logs/access-logs.module.ts diff --git a/backend/src/access-logs/access-logs.controller.spec.ts b/backend/src/access-logs/access-logs.controller.spec.ts new file mode 100644 index 0000000..0c6accf --- /dev/null +++ b/backend/src/access-logs/access-logs.controller.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AccessLogsController } from './access-logs.controller'; +import { AccessLogsService } from './access-logs.service'; +import { FilterAccessLogsDto } from './dto/filter-access-logs.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; + +describe('AccessLogsController', () => { + let controller: AccessLogsController; + let service: AccessLogsService; + + const mockAccessLogsService = { + findAll: jest.fn().mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 10, + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AccessLogsController], + providers: [ + { + provide: AccessLogsService, + useValue: mockAccessLogsService, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AccessLogsController); + service = module.get(AccessLogsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return paginated access logs for admin user', async () => { + const filterDto: FilterAccessLogsDto = { page: 1, limit: 10 }; + const result = await controller.getAccessLogs(filterDto); + + expect(service.findAll).toHaveBeenCalledWith(filterDto); + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('total'); + }); +}); diff --git a/backend/src/access-logs/access-logs.controller.ts b/backend/src/access-logs/access-logs.controller.ts new file mode 100644 index 0000000..1742a99 --- /dev/null +++ b/backend/src/access-logs/access-logs.controller.ts @@ -0,0 +1,27 @@ +import { + Controller, + Get, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AccessLogsService } from './access-logs.service'; +import { FilterAccessLogsDto } from './dto/filter-access-logs.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/guards/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; + +@Controller('admin/access-logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AccessLogsController { + constructor(private readonly accessLogsService: AccessLogsService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async getAccessLogs(@Query() filterDto: FilterAccessLogsDto) { + return this.accessLogsService.findAll(filterDto); + } +} diff --git a/backend/src/access-logs/access-logs.module.ts b/backend/src/access-logs/access-logs.module.ts new file mode 100644 index 0000000..6b649be --- /dev/null +++ b/backend/src/access-logs/access-logs.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AccessLog } from './entities/access-log.entity'; +import { AccessLogsService } from './access-logs.service'; +import { AccessLogsController } from './access-logs.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AccessLog])], + controllers: [AccessLogsController], + providers: [AccessLogsService], + exports: [AccessLogsService], +}) +export class AccessLogsModule {} diff --git a/backend/src/access-logs/dto/create-access-log.dto.ts b/backend/src/access-logs/dto/create-access-log.dto.ts index 970363d..581cdca 100644 --- a/backend/src/access-logs/dto/create-access-log.dto.ts +++ b/backend/src/access-logs/dto/create-access-log.dto.ts @@ -1,6 +1,20 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; + export class CreateAccessLogDto { - userId?: string; + @IsOptional() + @IsString() + userId?: string | null; + + @IsString() routePath: string; + + @IsString() httpMethod: string; - ipAddress?: string; + + @IsString() + ipAddress: string; + + @IsOptional() + @IsNumber() + statusCode?: number; } diff --git a/backend/src/access-logs/entities/access-log.entity.ts b/backend/src/access-logs/entities/access-log.entity.ts index 6702abe..7aecfca 100644 --- a/backend/src/access-logs/entities/access-log.entity.ts +++ b/backend/src/access-logs/entities/access-log.entity.ts @@ -24,4 +24,7 @@ export class AccessLog { @CreateDateColumn() createdAt: Date; + + @Column({ type: 'int', nullable: true }) + statusCode: number; } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 51ef429..c5b45e8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { WinstonModule } from 'nest-winston'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AccessLogsModule } from './access-logs/access-logs.module'; import { AuthModule } from './auth/auth.module'; import { buildWinstonOptions } from './common/logger.config'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; @@ -50,6 +51,7 @@ import { ConfigValidationSchema } from './config/config.validation'; synchronize: true, }), }), + AccessLogsModule, UsersModule, AuthModule, DocumentsModule, diff --git a/backend/src/common/middleware/logger.middleware.ts b/backend/src/common/middleware/logger.middleware.ts index 33fe79b..9fd166c 100644 --- a/backend/src/common/middleware/logger.middleware.ts +++ b/backend/src/common/middleware/logger.middleware.ts @@ -2,12 +2,14 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import type { Logger } from 'winston'; import { Request, Response, NextFunction } from 'express'; +import { AccessLogsService } from '../../access-logs/access-logs.service'; @Injectable() export class LoggerMiddleware implements NestMiddleware { constructor( @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: Logger, + private readonly accessLogsService: AccessLogsService, ) {} use(req: Request, res: Response, next: NextFunction) { @@ -21,13 +23,19 @@ export class LoggerMiddleware implements NestMiddleware { res.on('finish', () => { const duration = Date.now() - start; + const routePath = req.originalUrl || req.url; + const httpMethod = req.method; + const statusCode = res.statusCode; + const userId = (req as any).user?.id || null; + const payload: Record = { - method: req.method, - path: req.originalUrl || req.url, - status: res.statusCode, + method: httpMethod, + path: routePath, + status: statusCode, duration_ms: duration, user_agent: userAgent, ip, + userId, }; if (req.headers.authorization) { @@ -35,6 +43,19 @@ export class LoggerMiddleware implements NestMiddleware { } this.logger.info('http-request', payload); + + // Persist access log via AccessLogsService asynchronously + this.accessLogsService + .create({ + userId, + routePath, + httpMethod, + ipAddress: ip || '', + statusCode, + }) + .catch((err) => { + this.logger.error('Failed to save access log record:', err); + }); }); next(); From 8097de2f8b3364a33786f944f330d23f5e86d5b0 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Fri, 24 Jul 2026 07:04:55 +0100 Subject: [PATCH 2/6] Create package-lock.json --- package-lock.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ba7937e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "SMALDA", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 58d36b92ecb2309766cd942ee662e357d7ef2396 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Fri, 24 Jul 2026 07:15:32 +0100 Subject: [PATCH 3/6] feat(stellar): align Rust verifier anchoring with ManageData strategy (#857) --- contract/src/stellar.rs | 296 +++++++++++++++++++++++++++++++++++----- 1 file changed, 265 insertions(+), 31 deletions(-) diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index eaafd50..ebf2252 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Result}; use base64::Engine as _; use chrono::Utc; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use stellar_base::{ account::DataValue, crypto::KeyPair, @@ -25,10 +26,37 @@ pub struct TransactionRecord { pub verified: bool, } +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct VerificationResult { pub verified: bool, pub transaction_id: Option, pub timestamp: Option, + pub data_key: Option, + pub raw_value: Option, + pub decoded_value: Option, +} + +/// Verification details matching NestJS verification response payload format. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VerificationRecord { + pub hash: String, + pub anchored: bool, + pub data_key: String, + pub transaction_id: Option, + pub timestamp: Option, + pub raw_value_base64: Option, + pub decoded_value: Option, +} + +/// History entry for GET /verify/:hash/history (CT-03 / CT-04 compatibility). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HistoryEntry { + pub id: String, + pub transaction_hash: String, + pub created_at: String, + pub data_name: String, + pub data_value_base64: Option, + pub decoded_value: Option, } /// Successful result returned by [`StellarClient::anchor_hash`]. @@ -46,6 +74,8 @@ pub struct AnchorResult { #[derive(Debug, Deserialize)] struct HorizonAccount { sequence: String, + #[serde(default)] + data: HashMap, } /// Horizon transaction submission response (subset of fields). @@ -63,6 +93,28 @@ struct HorizonError { title: Option, } +/// Horizon operation list response. +#[derive(Debug, Deserialize)] +struct OperationsResponse { + _embedded: OperationsEmbedded, +} + +#[derive(Debug, Deserialize)] +struct OperationsEmbedded { + records: Vec, +} + +#[derive(Debug, Deserialize)] +struct OperationRecord { + id: String, + transaction_hash: String, + created_at: String, + #[serde(rename = "type")] + op_type: String, + name: Option, + value: Option, +} + impl StellarClient { pub fn new(horizon_url: &str) -> Self { Self { @@ -80,40 +132,223 @@ impl StellarClient { .unwrap_or(false) } - pub async fn verify_hash(&self, hash: &str) -> Result { - let url = format!("{}/transactions?memo={}", self.horizon_url, hash); - let resp = self.http_client.get(&url).send().await?; + /// Verifies a document hash against Horizon using the `ManageData` approach. + /// + /// Reads `account.data_attr` for key `"doc_" + &hash[..58]`. + pub async fn verify_hash( + &self, + hash: &str, + anchor_account_id: &str, + ) -> Result { + let account_url = format!("{}/accounts/{}", self.horizon_url, anchor_account_id); + let resp = self + .http_client + .get(&account_url) + .send() + .await + .map_err(|e| anyhow!("Failed to fetch account info from Horizon: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + return Err(anyhow!("Horizon account fetch failed with status {}", status)); + } + + let account: HorizonAccount = resp.json().await?; + let data_key = build_data_key(hash); - if resp.status().is_success() { - Ok(VerificationResult { - verified: true, - transaction_id: Some(String::new()), - timestamp: Some(0), + if let Some(b64_val) = account.data.get(&data_key) { + let decoded_bytes = base64::engine::general_purpose::STANDARD + .decode(b64_val) + .unwrap_or_else(|_| b64_val.as_bytes().to_vec()); + let decoded_str = String::from_utf8_lossy(&decoded_bytes).to_string(); + + Ok(VerificationRecord { + hash: hash.to_string(), + anchored: true, + data_key, + transaction_id: None, + timestamp: None, + raw_value_base64: Some(b64_val.clone()), + decoded_value: Some(decoded_str), }) } else { - Ok(VerificationResult { - verified: false, + Ok(VerificationRecord { + hash: hash.to_string(), + anchored: false, + data_key, transaction_id: None, timestamp: None, + raw_value_base64: None, + decoded_value: None, }) } } - pub async fn anchor_transfer(&self, _transfer_hash: &str, _memo: &str) -> Result<()> { - Ok(()) + /// Fetches all ManageData history entries for a given document hash (anchors, updates, transfers). + pub async fn get_hash_history( + &self, + hash: &str, + anchor_account_id: &str, + ) -> Result> { + let data_key = build_data_key(hash); + let transfer_key = build_transfer_key(hash); + let revocation_key = build_revocation_key(hash); + + let url = format!( + "{}/accounts/{}/operations?order=desc&limit=200", + self.horizon_url, anchor_account_id + ); + + let resp = self + .http_client + .get(&url) + .send() + .await + .map_err(|e| anyhow!("Failed to fetch account operations: {}", e))?; + + if !resp.status().is_success() { + return Err(anyhow!("Horizon operations fetch failed with status {}", resp.status())); + } + + let ops: OperationsResponse = resp.json().await?; + let mut history = Vec::new(); + + for op in ops._embedded.records { + if op.op_type == "manage_data" { + if let Some(ref name) = op.name { + if name == &data_key || name == &transfer_key || name == &revocation_key { + let decoded_value = op.value.as_ref().map(|v| { + base64::engine::general_purpose::STANDARD + .decode(v) + .map(|bytes| String::from_utf8_lossy(&bytes).to_string()) + .unwrap_or_else(|_| v.clone()) + }); + + history.push(HistoryEntry { + id: op.id, + transaction_hash: op.transaction_hash, + created_at: op.created_at, + data_name: name.clone(), + data_value_base64: op.value, + decoded_value, + }); + } + } + } + } + + Ok(history) + } + + /// Anchor a transfer record on Stellar using a `ManageData` operation. + pub async fn anchor_transfer( + &self, + transfer_hash: &str, + public_key: &str, + secret_key: &str, + ) -> Result { + info!( + "Anchoring transfer record {} via ManageData (account: {})", + &transfer_hash[..transfer_hash.len().min(16)], + public_key + ); + + let account_url = format!("{}/accounts/{}", self.horizon_url, public_key); + let acct_resp = self + .http_client + .get(&account_url) + .send() + .await + .map_err(|e| anyhow!("Failed to fetch account info: {}", e))?; + + if !acct_resp.status().is_success() { + return Err(anyhow!( + "Horizon {} when fetching account {}", + acct_resp.status().as_u16(), + public_key + )); + } + + let acct: HorizonAccount = acct_resp.json().await?; + let sequence: i64 = acct + .sequence + .parse() + .map_err(|_| anyhow!("Could not parse account sequence"))?; + + let transfer_key = build_transfer_key(transfer_hash); + let data_value = DataValue::from_slice(transfer_hash.as_bytes()) + .map_err(|e| anyhow!("DataValue error: {:?}", e))?; + + let op = Operation::new_manage_data() + .with_data_name(transfer_key) + .with_data_value(Some(data_value)) + .build() + .map_err(|e| anyhow!("Failed to build ManageData operation: {:?}", e))?; + + let keypair = KeyPair::from_secret_seed(secret_key) + .map_err(|e| anyhow!("Invalid secret key: {:?}", e))?; + + let network = if self.horizon_url.contains("testnet") { + Network::new_test() + } else { + Network::new_public() + }; + + let mut tx = Transaction::builder(keypair.public_key().clone(), sequence, MIN_BASE_FEE) + .add_operation(op) + .into_transaction() + .map_err(|e| anyhow!("Failed to build transaction: {:?}", e))?; + + tx.sign(&keypair, &network) + .map_err(|e| anyhow!("Failed to sign transaction: {:?}", e))?; + + let envelope: TransactionEnvelope = tx.into_envelope(); + let xdr_bytes = envelope + .xdr_bytes() + .map_err(|e| anyhow!("XDR serialization failed: {:?}", e))?; + let xdr_b64 = base64::engine::general_purpose::STANDARD.encode(&xdr_bytes); + + let submit_url = format!("{}/transactions", self.horizon_url); + let form_body = format!("tx={}", urlencoding::encode(&xdr_b64)); + + let submit_resp = self + .http_client + .post(&submit_url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form_body) + .send() + .await + .map_err(|e| anyhow!("Transaction submission failed: {}", e))?; + + if submit_resp.status().is_success() { + let tx_resp: HorizonTxResponse = submit_resp.json().await?; + let anchored_at = tx_resp + .created_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| Utc::now().timestamp()); + + Ok(AnchorResult { + tx_hash: tx_resp.hash, + ledger: tx_resp.ledger, + anchored_at, + }) + } else { + let status_code = submit_resp.status().as_u16(); + let err_text = submit_resp.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&err_text) + .ok() + .and_then(|e| e.detail.or(e.title)) + .unwrap_or(err_text); + Err(anyhow!("Horizon transfer anchor {} — {}", status_code, detail)) + } } /// Anchor a document hash to Stellar using a `ManageData` operation. /// /// # Key format - /// `"doc_" + &hash[..58]` — matches NestJS `buildDataKey()`. - /// - /// # Steps - /// 1. Fetch the signing account's current sequence number from Horizon. - /// 2. Build a `Transaction` with a single `ManageData` operation. - /// 3. Sign with `secret_key`. - /// 4. Encode as XDR base64 and submit to Horizon. - /// 5. Return the transaction hash, ledger, and timestamp. + /// `"doc_" + &hash[..58]` — matches NestJS `buildDataKey()`. pub async fn anchor_hash( &self, hash: &str, @@ -126,7 +361,6 @@ impl StellarClient { public_key ); - // 1. Fetch account sequence number. let account_url = format!("{}/accounts/{}", self.horizon_url, public_key); let acct_resp = self .http_client @@ -149,7 +383,6 @@ impl StellarClient { .parse() .map_err(|_| anyhow!("Could not parse account sequence"))?; - // 2. Build the ManageData operation. let data_key = build_data_key(hash); let data_value = DataValue::from_slice(hash.as_bytes()) .map_err(|e| anyhow!("DataValue error: {:?}", e))?; @@ -160,7 +393,6 @@ impl StellarClient { .build() .map_err(|e| anyhow!("Failed to build ManageData operation: {:?}", e))?; - // 3. Build and sign the transaction. let keypair = KeyPair::from_secret_seed(secret_key) .map_err(|e| anyhow!("Invalid secret key: {:?}", e))?; @@ -178,14 +410,12 @@ impl StellarClient { tx.sign(&keypair, &network) .map_err(|e| anyhow!("Failed to sign transaction: {:?}", e))?; - // 4. Encode to XDR base64. let envelope: TransactionEnvelope = tx.into_envelope(); let xdr_bytes = envelope .xdr_bytes() .map_err(|e| anyhow!("XDR serialization failed: {:?}", e))?; let xdr_b64 = base64::engine::general_purpose::STANDARD.encode(&xdr_bytes); - // 5. Submit. let submit_url = format!("{}/transactions", self.horizon_url); let form_body = format!("tx={}", urlencoding::encode(&xdr_b64)); @@ -225,7 +455,7 @@ impl StellarClient { /// Record a document revocation on Stellar using a `ManageData` operation. /// - /// Key: `"revoked_" + &hash[..56]` (max 64 bytes). + /// Key: `"revoked_" + &hash[..56]` (max 64 bytes). /// Value: the revocation JSON payload (truncated to 64 bytes). pub async fn anchor_revocation( &self, @@ -240,7 +470,6 @@ impl StellarClient { public_key ); - // Fetch account sequence number. let account_url = format!("{}/accounts/{}", self.horizon_url, public_key); let acct_resp = self .http_client @@ -264,7 +493,6 @@ impl StellarClient { let revocation_key = build_revocation_key(hash); - // Stellar ManageData values are max 64 bytes. let raw = revocation_json.as_bytes(); let value_bytes = &raw[..raw.len().min(64)]; let data_value = @@ -336,13 +564,19 @@ impl StellarClient { } } -/// Build the ManageData key: `"doc_" + &hash[..58]` (max 62 bytes ≤ 64-byte limit). +/// Build the ManageData key: `"doc_" + &hash[..58]` (max 62 bytes ≤ 64-byte limit). pub fn build_data_key(hash: &str) -> String { let suffix_len = hash.len().min(58); format!("doc_{}", &hash[..suffix_len]) } -/// Build the revocation ManageData key: `"revoked_" + &hash[..56]` (max 64 bytes). +/// Build the transfer ManageData key: `"trf_" + &hash[..58]` (max 62 bytes). +pub fn build_transfer_key(hash: &str) -> String { + let suffix_len = hash.len().min(58); + format!("trf_{}", &hash[..suffix_len]) +} + +/// Build the revocation ManageData key: `"revoked_" + &hash[..56]` (max 64 bytes). pub fn build_revocation_key(hash: &str) -> String { let suffix_len = hash.len().min(56); format!("revoked_{}", &hash[..suffix_len]) @@ -362,4 +596,4 @@ mod urlencoding { } out } -} +} \ No newline at end of file From 8e17856b3b9b0a2e33392af479c0a78723a12f58 Mon Sep 17 00:00:00 2001 From: mftee Date: Fri, 24 Jul 2026 08:22:46 +0100 Subject: [PATCH 4/6] style(contract): fix cargo fmt violations in stellar.rs Wraps three anyhow! calls that exceeded the line-width limit and adds the missing trailing newline at EOF, matching `cargo fmt --check`. --- contract/src/stellar.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index ebf2252..03f9e4b 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -150,7 +150,10 @@ impl StellarClient { if !resp.status().is_success() { let status = resp.status().as_u16(); - return Err(anyhow!("Horizon account fetch failed with status {}", status)); + return Err(anyhow!( + "Horizon account fetch failed with status {}", + status + )); } let account: HorizonAccount = resp.json().await?; @@ -207,7 +210,10 @@ impl StellarClient { .map_err(|e| anyhow!("Failed to fetch account operations: {}", e))?; if !resp.status().is_success() { - return Err(anyhow!("Horizon operations fetch failed with status {}", resp.status())); + return Err(anyhow!( + "Horizon operations fetch failed with status {}", + resp.status() + )); } let ops: OperationsResponse = resp.json().await?; @@ -341,7 +347,11 @@ impl StellarClient { .ok() .and_then(|e| e.detail.or(e.title)) .unwrap_or(err_text); - Err(anyhow!("Horizon transfer anchor {} — {}", status_code, detail)) + Err(anyhow!( + "Horizon transfer anchor {} — {}", + status_code, + detail + )) } } @@ -596,4 +606,4 @@ mod urlencoding { } out } -} \ No newline at end of file +} From 34fad9132a40b70ae0abfea01158f108e77170c4 Mon Sep 17 00:00:00 2001 From: mftee Date: Fri, 24 Jul 2026 09:10:56 +0100 Subject: [PATCH 5/6] fix(contract): update call sites for the account.data_attr anchoring refactor This PR changed StellarClient::verify_hash and ::anchor_transfer to take an explicit anchor account id, and renamed VerificationRecord::verified to ::anchored, but didn't update the callers in lib.rs, breaking the build (E0061 x3, E0609 x3). Adds stellar::derive_account_id(secret_key) to resolve the single service account (public key) that all ManageData entries are read from and written to, matching this PR's stated goal of aligning with the NestJS backend's shared-account convention. Uses it at all four call sites (record_transfer's anchor_transfer call, and the three verify_hash calls in verify_document/verify_single_hash), and updates the three .verified -> .anchored field accesses to match the renamed field. --- contract/src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++----- contract/src/stellar.rs | 10 ++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/contract/src/lib.rs b/contract/src/lib.rs index d79a714..00b0981 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -24,7 +24,7 @@ use tracing::{info, warn}; use cache::CacheBackend; use hash_validator::{HashValidator, ValidationError as HashValidationError}; use metrics::MetricsRegistry; -use stellar::{StellarClient, TransactionRecord}; +use stellar::{derive_account_id, StellarClient, TransactionRecord}; // Application state #[derive(Clone)] @@ -261,7 +261,17 @@ pub async fn record_transfer( let transfer_hash = compute_transfer_hash(&req); let memo = build_transfer_memo(&transfer_hash); - if let Err(e) = state.stellar.anchor_transfer(&transfer_hash, &memo).await { + let anchor_account_id = derive_account_id(&state.stellar_secret_key).map_err(|e| { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if let Err(e) = state + .stellar + .anchor_transfer(&transfer_hash, &anchor_account_id, &state.stellar_secret_key) + .await + { warn!("Failed to anchor transfer on Stellar: {}", e); state.metrics.increment_error_count(); return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -346,8 +356,21 @@ pub async fn verify_document( state.metrics.increment_cache_misses(); + let anchor_account_id = match derive_account_id(&state.stellar_secret_key) { + Ok(id) => id, + Err(e) => { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + // Query Stellar blockchain - let result = match state.stellar.verify_hash(&normalized_hash).await { + let result = match state + .stellar + .verify_hash(&normalized_hash, &anchor_account_id) + .await + { Ok(verification) => verification, Err(e) => { warn!("Stellar query failed: {}", e); @@ -357,7 +380,7 @@ pub async fn verify_document( }; let response = VerifyResponse { - verified: result.verified, + verified: result.anchored, transaction_id: result.transaction_id, timestamp: result.timestamp, cached: false, @@ -514,8 +537,28 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { state.metrics.increment_cache_misses(); + let anchor_account_id = match derive_account_id(&state.stellar_secret_key) { + Ok(id) => id, + Err(e) => { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + + return BatchVerifyItem { + hash, + verified: false, + transaction_id: None, + timestamp: None, + error: Some(format!("failed to derive anchor account id: {}", e)), + }; + } + }; + // Query Stellar blockchain - let result = match state.stellar.verify_hash(&normalized_hash).await { + let result = match state + .stellar + .verify_hash(&normalized_hash, &anchor_account_id) + .await + { Ok(verification) => verification, Err(e) => { warn!("Stellar query failed for hash {}: {}", normalized_hash, e); @@ -533,7 +576,7 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { // Cache the result let cache_response = VerifyResponse { - verified: result.verified, + verified: result.anchored, transaction_id: result.transaction_id.clone(), timestamp: result.timestamp, cached: false, @@ -551,7 +594,7 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { BatchVerifyItem { hash, - verified: result.verified, + verified: result.anchored, transaction_id: result.transaction_id, timestamp: result.timestamp, error: None, diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index 03f9e4b..675aba9 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -592,6 +592,16 @@ pub fn build_revocation_key(hash: &str) -> String { format!("revoked_{}", &hash[..suffix_len]) } +/// Derive the Stellar account ID (public key) that reads/writes go through, +/// given the service's configured secret key. All `ManageData` entries are +/// anchored under this single account, so verification and history lookups +/// query it directly rather than requiring a caller-supplied account. +pub fn derive_account_id(secret_key: &str) -> Result { + let keypair = KeyPair::from_secret_seed(secret_key) + .map_err(|e| anyhow!("Invalid secret key: {:?}", e))?; + Ok(keypair.public_key().account_id()) +} + /// Minimal percent-encoding for the `tx=` form field. mod urlencoding { pub fn encode(s: &str) -> String { From 786b78fbc7ab7606a58fc0bfd00cabad80736036 Mon Sep 17 00:00:00 2001 From: mftee Date: Fri, 24 Jul 2026 09:18:28 +0100 Subject: [PATCH 6/6] style(contract): wrap anchor_transfer call to satisfy cargo fmt --- contract/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 00b0981..a555e88 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -269,7 +269,11 @@ pub async fn record_transfer( if let Err(e) = state .stellar - .anchor_transfer(&transfer_hash, &anchor_account_id, &state.stellar_secret_key) + .anchor_transfer( + &transfer_hash, + &anchor_account_id, + &state.stellar_secret_key, + ) .await { warn!("Failed to anchor transfer on Stellar: {}", e);