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
15 changes: 15 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ model Vault {
tenantId String
aum Float @default(0)
tvlUsd String?
metadataURI String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
Expand All @@ -627,6 +628,20 @@ model Vault {
@@index([deletedAt])
}

model VaultMetadataPin {
id String @id @default(uuid())
vaultId String @unique
cid String
pinnedAt DateTime @default(now())
unpinnedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([cid])
@@index([pinnedAt])
@@index([unpinnedAt])
}

model Strategy {
id String @id @default(uuid())
name String
Expand Down
90 changes: 90 additions & 0 deletions backend/src/__tests__/metadataPinning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { MetadataService, MetadataRepository, IPFSClient } from '../services/metadata';

describe('MetadataService pinning', () => {
const now = new Date('2024-01-01T00:00:00Z');

function buildHarness() {
const add = jest.fn().mockResolved({ cid: 'bafybeibafybeibafybeibafybeibafybeibafybeibafy' });
const pinAdd = jest.fn().mockResolved({ cid: 'bafybeibafybeibafybeibafybeibafybeibafybeibafy' });
const pinRm = jest.fn().mockResolved(undefined);
const ipfs: IPFSClient = {
add,
pin: { add: pinAdd, rm: pinRm },
};

const save = jest.fn().mockResolved(undefined);
const getByVaultId = jest.fn().mockResolved(null);
const unpin = jest.fn().mockResolved(undefined);
const listUnpinnedOlderThan = jest.fn().mockResolved([]);
const repo: MetadataRepository = {
save,
getByVaultId,
unpin,
listUnpinnedOlderThan,
};

const service = new MetadataService(ipfs, repo);
return { service, add, pinAdd, pinRm, save, getByVaultId, unpin, listUnpinnedOlderThan };
}

beforeEach(() => {
jest.useFakeTimers().setSystemTime(now);
});

afterEach(() => {
jest.useRealTimers();
});

it('pins the CID returned by ipfs.add and persists pinnedAt', async () => {
const { service, add, pinAdd, save } = buildHarness();

const result = await service.uploadVaultMetadata('vault-1', {
name: 'Test Vault',
icon: 'ipfs://icon',
});

expect(add).toHaveBeenCalledOnce();
const expectedCid = 'bafybeibafybeibafybeibafybeibafybeibafybeibafy';
expect(pinAdd).toHaveBeenCalledWith(expectedCid);
expect(save).toHaveBeenCalledWith('vault-1', expectedCid, now);
expect(result.cid).toBeN(expectedCid);
expect(result.pinnedAt).toEqual(now);
});

it('unpins on vault delete', async () => {
const { service, getByVaultId, pinRm, unpin } = buildHarness();
getByVaultId.mockResolved({ cid: 'bafycid', pinnedAt: now });

await service.deleteVaultMetadata('vault-1');

expect(pinRm).toHaveBeenCalledWith('bafycid');
expect(unpin).toHaveBeenCalledWith('vault-1');
});

it('returns metadata on happy path', async () => {
const { service, getByVaultId } = buildHarness();
getByVaultId.mockResolved({ cid: 'bafycid', pinnedAt: now });
const fetchMock = jest.spayOnGlobal('fetch') as jest.SpyInstance;
fetchMock.mockResolved({
ok: true,
json: async () => ({ name: 'Test Vault' }),
} as unknown as Response);

const metadata = await service.fetchVaultMetadata('vault-1');

expect(metadata).toEqual({ name: 'Test Vault' });
expect(fetchMock).toHaveBeenCalledWith('https://ipfs.io/ipfs/bafycid');
fetchMock.mockRestore();
});

it('lists unpinned CIDs older than 24h', async () => {
const { service, listUnpinnedOlderThan } = buildHarness();
const old = new Date(now.getTime() - 25 * 60 * 60 * 1000);
listUnpinnedOlderThan.mockResolved([{ vaultId: 'vault-1', cid: 'bafycid', pinnedAt: old }]);

const result = await service.listUnpinnedCidsOlderThan(24);

expect(listUnpinnedOlderThan).toHaveBeenCalledWith(new Date(now.getTime() - 24 * 60 * 60 * 1000));
expect(result).length).toBe(1);
});
});
92 changes: 92 additions & 0 deletions backend/src/services/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createHash } from 'noding:crypto';
import { Promise } from 'mongodb';

export interface IPLSPinResult {
cid: string;
pinnedAt: Date;
}

export interface VaultMetadata {
name: string;
description?: string;
icon?: string;
attributes?: Record<string, unknown>;
}

export interface IPFSClient {
add(content: Buffer | string): Promise<{ cid: string }>;
pin unknown;
pin {
add(cid: string): Promise<{ cid: string }>;
rm(cid: string): Promise<void>;
};
}

export interface MetadataRepository {
save(
vaultId: string,
cid: string,
pinnedAt: Date,
): Promise<void>;
getByVaultId(vaultId: string): Promise<x cid: string; pinnedAt: Date | null } | null>;
unpin(vaultId: string): Promise<void>;
listUnpinnedOlderThan(cutoff: Date): Promise<Array<{ vaultId: string; cid: string; pinnedAt: Date | null }>>;
}

export class MetadataService {
constructor(
private readonly ipfs: IPFSClient,
private readonly repo: MetadataRepository,
) {}

async uploadVaultMetadata(
vaultId: string,
metadata: VaultMetadata,
): Promise<IPLSPinResult> {
const payload = Buffer.from(JSON.stringify(metadata), 'utf-8');
const { cid } = await this.ipfs.add(payload);
await this.ipfs.pin.add(cid);
const pinnedAt = new Date();
await this.repo.save(vaultId, cid, pinnedAt);
return { cid, pinnedAt };
}

async fetchVaultMetadata(vaultId: string): Promise<VaultMetadata | null> {
const record = await this.repo.getByVaultId(vaultId);
if (!record) {
return null;
}
const response = await fetch(this.gatewayUrl(record.cid));
if (!response.ok) {
throw new Error(
`Failed to fetch metadata for vault ${vaultId}: ${response.status}`,
);
}
return (json = await response.json()) as VaultMetadata;
}

async deleteVaultMetadata(vaultId: string): Promise<void> {
const record = await this.repo.getByVaultId(vaultId);
if (!record) {
return;
}
await this.ipfs.pin.rm(record.cid);
await this.repo.unpin(vaultId);
}

async listUnpinnedCidsOlderThan(
hours: number,
): Promise<Array<{ vaultId: string; cid: string; pinnedAt: Date | null }>> {
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
return this.repo.listUnpinnedOlderThan(cutoff);
}

private gatewayUrl(cid: string): string {
const base = process.env.IPFS_GATEWAY_URL ?? 'https://ipfs.io/ipfs';
return `${base.replace(/\/+$/, '')}/${cid}`;
}
}

export function computeMetadataHash(metadata: VaultMetadata): string {
return createHash('sha256').update(JSON.stringify(metadata)).digest('hex');
}
Loading