Skip to content

Commit 75a0116

Browse files
author
llins
authored
Merge pull request #228 from Mac-5/feature/stellar-hash-anchoring
Feature/stellar hash anchoring
2 parents 71bc3e7 + 08a096a commit 75a0116

File tree

7 files changed

+366
-2
lines changed

7 files changed

+366
-2
lines changed

.env.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ AWS_S3_BUCKET=petchain-files
4141
STELLAR_WALLET_MASTER_KEY=your-32-byte-base64-key
4242
STELLAR_DEFAULT_NETWORK=TESTNET
4343

44+
# Stellar Hash Anchoring — dedicated keypair for medical record anchoring
45+
# Generate with: stellar-sdk keypair generate (or use Stellar Laboratory)
46+
STELLAR_ANCHOR_SECRET_KEY=your-stellar-secret-key-for-anchoring
47+
4448
# Application Configuration
4549
NODE_ENV=development
4650
PORT=3000

backend/src/modules/blockchain/blockchain-sync.module.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { BlockchainSync } from './entities/blockchain-sync.entity';
4+
import { MedicalRecordAnchor } from './entities/medical-record-anchor.entity';
45
import { BlockchainSyncService } from './blockchain-sync.service';
56
import { BlockchainSyncController } from './blockchain-sync.controller';
67
import { StellarService } from './stellar.service';
@@ -10,10 +11,15 @@ import { ContractManagementService } from './contract-management.service';
1011
import { ContractInteractionService } from './contract-interaction.service';
1112
import { PaymentAutomationService } from './payment-automation.service';
1213
import { ContractEventMonitorService } from './contract-event-monitor.service';
14+
import { HashAnchoringService } from './hash-anchoring.service';
15+
import { HashAnchoringController } from './hash-anchoring.controller';
16+
import { MedicalRecord } from '../medical-records/entities/medical-record.entity';
1317

1418
@Module({
15-
imports: [TypeOrmModule.forFeature([BlockchainSync])],
16-
controllers: [BlockchainSyncController],
19+
imports: [
20+
TypeOrmModule.forFeature([BlockchainSync, MedicalRecordAnchor, MedicalRecord]),
21+
],
22+
controllers: [BlockchainSyncController, HashAnchoringController],
1723
providers: [
1824
BlockchainSyncService,
1925
StellarService,
@@ -23,6 +29,7 @@ import { ContractEventMonitorService } from './contract-event-monitor.service';
2329
ContractInteractionService,
2430
PaymentAutomationService,
2531
ContractEventMonitorService,
32+
HashAnchoringService,
2633
],
2734
exports: [
2835
BlockchainSyncService,
@@ -32,6 +39,7 @@ import { ContractEventMonitorService } from './contract-event-monitor.service';
3239
ContractInteractionService,
3340
PaymentAutomationService,
3441
ContractEventMonitorService,
42+
HashAnchoringService,
3543
],
3644
})
3745
export class BlockchainSyncModule {}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
Index,
8+
} from 'typeorm';
9+
10+
export enum AnchorStatus {
11+
PENDING = 'pending',
12+
QUEUED = 'queued',
13+
ANCHORED = 'anchored',
14+
CONFIRMED = 'confirmed',
15+
FAILED = 'failed',
16+
}
17+
18+
@Entity('medical_record_anchors')
19+
export class MedicalRecordAnchor {
20+
@PrimaryGeneratedColumn('uuid')
21+
id: string;
22+
23+
@Column()
24+
@Index()
25+
recordId: string;
26+
27+
/** SHA-256 of the canonical record payload */
28+
@Column()
29+
recordHash: string;
30+
31+
@Column({ type: 'enum', enum: AnchorStatus, default: AnchorStatus.PENDING })
32+
@Index()
33+
status: AnchorStatus;
34+
35+
/** Stellar transaction hash once submitted */
36+
@Column({ nullable: true })
37+
txHash: string;
38+
39+
/** Ledger sequence number of confirmed transaction */
40+
@Column({ type: 'bigint', nullable: true })
41+
ledgerSequence: number;
42+
43+
/** Fee paid in stroops */
44+
@Column({ nullable: true })
45+
feePaid: string;
46+
47+
/** Batch ID when anchored as part of a batch */
48+
@Column({ nullable: true })
49+
@Index()
50+
batchId: string;
51+
52+
@Column({ default: 0 })
53+
retryCount: number;
54+
55+
@Column({ type: 'text', nullable: true })
56+
lastError: string;
57+
58+
@Column({ type: 'timestamp', nullable: true })
59+
anchoredAt: Date;
60+
61+
@Column({ type: 'timestamp', nullable: true })
62+
confirmedAt: Date;
63+
64+
@CreateDateColumn()
65+
createdAt: Date;
66+
67+
@UpdateDateColumn()
68+
updatedAt: Date;
69+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
2+
import { HashAnchoringService } from './hash-anchoring.service';
3+
4+
@Controller('medical-records/anchor')
5+
export class HashAnchoringController {
6+
constructor(private readonly anchoringService: HashAnchoringService) {}
7+
8+
@Post(':recordId')
9+
queue(@Param('recordId') recordId: string) {
10+
return this.anchoringService.queueAnchor(recordId);
11+
}
12+
13+
@Post('batch')
14+
queueBatch(@Body('recordIds') recordIds: string[]) {
15+
return this.anchoringService.queueBatch(recordIds);
16+
}
17+
18+
@Get(':recordId/status')
19+
status(@Param('recordId') recordId: string) {
20+
return this.anchoringService.getAnchorStatus(recordId);
21+
}
22+
23+
@Get(':recordId/verify')
24+
verify(@Param('recordId') recordId: string) {
25+
return this.anchoringService.verifyAnchor(recordId);
26+
}
27+
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository, In } from 'typeorm';
4+
import { Cron, CronExpression } from '@nestjs/schedule';
5+
import * as crypto from 'crypto';
6+
import * as StellarSdk from '@stellar/stellar-sdk';
7+
import { ConfigService } from '@nestjs/config';
8+
import { MedicalRecordAnchor, AnchorStatus } from './entities/medical-record-anchor.entity';
9+
import { MedicalRecord } from '../medical-records/entities/medical-record.entity';
10+
11+
const MAX_OPS_PER_TX = 100; // Stellar max operations per transaction
12+
const MAX_RETRIES = 3;
13+
const MEMO_PREFIX = 'MR:'; // memo prefix to identify medical record batches
14+
15+
@Injectable()
16+
export class HashAnchoringService {
17+
private readonly logger = new Logger(HashAnchoringService.name);
18+
private server: StellarSdk.Horizon.Server;
19+
private keypair: StellarSdk.Keypair;
20+
private networkPassphrase: string;
21+
22+
constructor(
23+
@InjectRepository(MedicalRecordAnchor)
24+
private readonly anchorRepo: Repository<MedicalRecordAnchor>,
25+
@InjectRepository(MedicalRecord)
26+
private readonly recordRepo: Repository<MedicalRecord>,
27+
private readonly configService: ConfigService,
28+
) {
29+
const horizonUrl =
30+
this.configService.get<string>('STELLAR_TESTNET_HORIZON_URL') ||
31+
'https://horizon-testnet.stellar.org';
32+
this.server = new StellarSdk.Horizon.Server(horizonUrl);
33+
34+
const network = this.configService.get<string>('STELLAR_DEFAULT_NETWORK') || 'TESTNET';
35+
this.networkPassphrase =
36+
network === 'PUBLIC'
37+
? StellarSdk.Networks.PUBLIC
38+
: StellarSdk.Networks.TESTNET;
39+
40+
const secret = this.configService.get<string>('STELLAR_ANCHOR_SECRET_KEY');
41+
if (secret) {
42+
this.keypair = StellarSdk.Keypair.fromSecret(secret);
43+
this.logger.log(`HashAnchoringService ready — account: ${this.keypair.publicKey()}`);
44+
} else {
45+
this.logger.warn('STELLAR_ANCHOR_SECRET_KEY not set — anchoring disabled');
46+
}
47+
}
48+
49+
// ─── Public API ────────────────────────────────────────────────────────────
50+
51+
/** Queue a single medical record for anchoring */
52+
async queueAnchor(recordId: string): Promise<MedicalRecordAnchor> {
53+
const existing = await this.anchorRepo.findOne({
54+
where: { recordId, status: In([AnchorStatus.PENDING, AnchorStatus.QUEUED, AnchorStatus.ANCHORED]) },
55+
});
56+
if (existing) return existing;
57+
58+
const record = await this.recordRepo.findOne({ where: { id: recordId } });
59+
if (!record) throw new Error(`Medical record ${recordId} not found`);
60+
61+
const recordHash = this.hashRecord(record);
62+
const anchor = this.anchorRepo.create({ recordId, recordHash, status: AnchorStatus.QUEUED });
63+
return this.anchorRepo.save(anchor);
64+
}
65+
66+
/** Queue multiple records — returns immediately, processing is async */
67+
async queueBatch(recordIds: string[]): Promise<{ queued: number; batchId: string }> {
68+
const batchId = crypto.randomUUID();
69+
const records = await this.recordRepo.findBy({ id: In(recordIds) });
70+
71+
const anchors = records.map((r) =>
72+
this.anchorRepo.create({
73+
recordId: r.id,
74+
recordHash: this.hashRecord(r),
75+
status: AnchorStatus.QUEUED,
76+
batchId,
77+
}),
78+
);
79+
80+
await this.anchorRepo.save(anchors);
81+
return { queued: anchors.length, batchId };
82+
}
83+
84+
/** Get anchor status for a record */
85+
async getAnchorStatus(recordId: string): Promise<MedicalRecordAnchor | null> {
86+
return this.anchorRepo.findOne({
87+
where: { recordId },
88+
order: { createdAt: 'DESC' },
89+
});
90+
}
91+
92+
/** Verify a record's current hash matches what's on-chain */
93+
async verifyAnchor(recordId: string): Promise<{
94+
verified: boolean;
95+
currentHash: string;
96+
anchoredHash: string;
97+
txHash: string;
98+
confirmedAt: Date;
99+
}> {
100+
const anchor = await this.anchorRepo.findOne({
101+
where: { recordId, status: AnchorStatus.CONFIRMED },
102+
order: { createdAt: 'DESC' },
103+
});
104+
105+
if (!anchor) throw new Error(`No confirmed anchor found for record ${recordId}`);
106+
107+
const record = await this.recordRepo.findOne({ where: { id: recordId } });
108+
const currentHash = this.hashRecord(record);
109+
110+
return {
111+
verified: currentHash === anchor.recordHash,
112+
currentHash,
113+
anchoredHash: anchor.recordHash,
114+
txHash: anchor.txHash,
115+
confirmedAt: anchor.confirmedAt,
116+
};
117+
}
118+
119+
// ─── Batch Processing (cron every 5 min) ───────────────────────────────────
120+
121+
@Cron(CronExpression.EVERY_5_MINUTES)
122+
async processPendingQueue(): Promise<void> {
123+
if (!this.keypair) return;
124+
125+
const pending = await this.anchorRepo.find({
126+
where: { status: AnchorStatus.QUEUED },
127+
order: { createdAt: 'ASC' },
128+
take: MAX_OPS_PER_TX,
129+
});
130+
131+
if (!pending.length) return;
132+
133+
this.logger.log(`Processing batch of ${pending.length} anchors`);
134+
await this.anchorBatch(pending);
135+
}
136+
137+
/** Poll ANCHORED records to confirm ledger inclusion */
138+
@Cron(CronExpression.EVERY_MINUTE)
139+
async confirmPendingTransactions(): Promise<void> {
140+
if (!this.keypair) return;
141+
142+
const anchored = await this.anchorRepo.find({
143+
where: { status: AnchorStatus.ANCHORED },
144+
});
145+
146+
for (const anchor of anchored) {
147+
await this.confirmTransaction(anchor);
148+
}
149+
}
150+
151+
// ─── Core Anchoring Logic ──────────────────────────────────────────────────
152+
153+
private async anchorBatch(anchors: MedicalRecordAnchor[]): Promise<void> {
154+
// Group by batchId (or treat as one batch if mixed)
155+
const batchId = anchors[0].batchId || crypto.randomUUID();
156+
157+
try {
158+
const account = await this.server.loadAccount(this.keypair.publicKey());
159+
160+
// Cost optimisation: pack up to MAX_OPS_PER_TX manageData ops in one tx
161+
const builder = new StellarSdk.TransactionBuilder(account, {
162+
fee: StellarSdk.BASE_FEE,
163+
networkPassphrase: this.networkPassphrase,
164+
}).setTimeout(60);
165+
166+
for (const anchor of anchors) {
167+
// manageData key max 64 bytes — use first 28 chars of hash (hex) + prefix
168+
const key = `MR:${anchor.recordHash.substring(0, 28)}`;
169+
// value: full 64-char hex hash (32 bytes)
170+
builder.addOperation(
171+
StellarSdk.Operation.manageData({
172+
name: key,
173+
value: Buffer.from(anchor.recordHash, 'hex'),
174+
}),
175+
);
176+
}
177+
178+
const tx = builder.build();
179+
tx.sign(this.keypair);
180+
181+
const result = await this.server.submitTransaction(tx);
182+
const txHash = result.hash;
183+
184+
// Mark all as ANCHORED
185+
await this.anchorRepo.save(
186+
anchors.map((a) => ({
187+
...a,
188+
txHash,
189+
batchId,
190+
status: AnchorStatus.ANCHORED,
191+
anchoredAt: new Date(),
192+
lastError: null,
193+
})),
194+
);
195+
196+
this.logger.log(`Batch anchored — txHash: ${txHash}, records: ${anchors.length}`);
197+
} catch (error) {
198+
this.logger.error(`Batch anchor failed: ${error.message}`);
199+
await this.anchorRepo.save(
200+
anchors.map((a) => ({
201+
...a,
202+
status: a.retryCount >= MAX_RETRIES ? AnchorStatus.FAILED : AnchorStatus.QUEUED,
203+
retryCount: a.retryCount + 1,
204+
lastError: error.message,
205+
})),
206+
);
207+
}
208+
}
209+
210+
private async confirmTransaction(anchor: MedicalRecordAnchor): Promise<void> {
211+
try {
212+
const tx = await this.server.transactions().transaction(anchor.txHash).call();
213+
if (tx.successful) {
214+
await this.anchorRepo.save({
215+
...anchor,
216+
status: AnchorStatus.CONFIRMED,
217+
ledgerSequence: tx.ledger,
218+
feePaid: tx.fee_charged,
219+
confirmedAt: new Date(),
220+
});
221+
this.logger.log(`Confirmed anchor for record ${anchor.recordId} at ledger ${tx.ledger}`);
222+
}
223+
} catch {
224+
// Transaction not yet in ledger — will retry next cron tick
225+
}
226+
}
227+
228+
// ─── Hash Generation ───────────────────────────────────────────────────────
229+
230+
/** Canonical SHA-256 hash of a medical record's immutable fields */
231+
hashRecord(record: Partial<MedicalRecord>): string {
232+
const payload = JSON.stringify({
233+
id: record.id,
234+
petId: record.petId,
235+
vetId: record.vetId,
236+
recordType: record.recordType,
237+
visitDate: record.visitDate,
238+
diagnosis: record.diagnosis,
239+
treatment: record.treatment,
240+
notes: record.notes,
241+
version: record.version,
242+
});
243+
return crypto.createHash('sha256').update(payload).digest('hex');
244+
}
245+
}

0 commit comments

Comments
 (0)