From 10265e4fa252197e9b1ec56a2192bd520fdef262 Mon Sep 17 00:00:00 2001 From: bluegate Date: Tue, 29 Sep 2026 05:54:52 -0700 Subject: [PATCH] fix: Upload vault metadata to IPFS without pinning causing garbag (#1449) --- backend/prisma/schema.prisma | 15 +++ backend/src/__tests__/metadataPinning.test.ts | 90 ++++++++++++++++++ backend/src/services/metadata.ts | 92 +++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 backend/src/__tests__/metadataPinning.test.ts create mode 100644 backend/src/services/metadata.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 20e8c2e41..d74f4ddfb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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? @@ -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 diff --git a/backend/src/__tests__/metadataPinning.test.ts b/backend/src/__tests__/metadataPinning.test.ts new file mode 100644 index 000000000..4aa19cd00 --- /dev/null +++ b/backend/src/__tests__/metadataPinning.test.ts @@ -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); + }); +}); diff --git a/backend/src/services/metadata.ts b/backend/src/services/metadata.ts new file mode 100644 index 000000000..cfd4d0a0a --- /dev/null +++ b/backend/src/services/metadata.ts @@ -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; +} + +export interface IPFSClient { + add(content: Buffer | string): Promise<{ cid: string }>; + pin unknown; + pin { + add(cid: string): Promise<{ cid: string }>; + rm(cid: string): Promise; + }; +} + +export interface MetadataRepository { + save( + vaultId: string, + cid: string, + pinnedAt: Date, + ): Promise; + getByVaultId(vaultId: string): Promise; + unpin(vaultId: string): Promise; + listUnpinnedOlderThan(cutoff: Date): Promise>; +} + +export class MetadataService { + constructor( + private readonly ipfs: IPFSClient, + private readonly repo: MetadataRepository, + ) {} + + async uploadVaultMetadata( + vaultId: string, + metadata: VaultMetadata, + ): Promise { + 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 { + 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 { + 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> { + 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'); +}