From 7ed6c19130f03fefb8cf2f18faf9527fc393024b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sat, 19 Sep 2026 12:12:51 +0200 Subject: [PATCH] feat: add administrative user estate evidence export --- ...r-estate-evidence-admin.controller.test.ts | 149 +++ .../controllers/avax-swap.controller.ts | 46 + .../user-estate-evidence-admin.route.test.ts | 21 + .../avax-swap/routes/avax-swap.routes.ts | 13 + .../user-identity-evidence-builder.test.ts | 891 ++++++++++++++++++ .../user-identity-evidence.service.test.ts | 380 ++++++++ .../user-identity-evidence.service.ts | 421 +++++++++ 7 files changed, 1921 insertions(+) create mode 100644 backend/src/modules/avax-swap/controllers/__tests__/user-estate-evidence-admin.controller.test.ts create mode 100644 backend/src/modules/avax-swap/routes/__tests__/user-estate-evidence-admin.route.test.ts create mode 100644 backend/src/shared/services/__tests__/user-identity-evidence-builder.test.ts create mode 100644 backend/src/shared/services/__tests__/user-identity-evidence.service.test.ts create mode 100644 backend/src/shared/services/user-identity-evidence.service.ts diff --git a/backend/src/modules/avax-swap/controllers/__tests__/user-estate-evidence-admin.controller.test.ts b/backend/src/modules/avax-swap/controllers/__tests__/user-estate-evidence-admin.controller.test.ts new file mode 100644 index 0000000..859288a --- /dev/null +++ b/backend/src/modules/avax-swap/controllers/__tests__/user-estate-evidence-admin.controller.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextFunction, Request, Response } from "express"; + +const mocks = vi.hoisted(() => ({ + isPhase2EvidenceAdmin: vi.fn(), + exportUserIdentityEvidenceAdmin: vi.fn(), +})); + +vi.mock("../../../../shared/services/transaction-evidence.service", () => ({ + isPhase2EvidenceAdmin: mocks.isPhase2EvidenceAdmin, +})); + +vi.mock("../../../../shared/services/user-identity-evidence.service", () => ({ + exportUserIdentityEvidenceAdmin: + mocks.exportUserIdentityEvidenceAdmin, +})); + +import * as controller from "../avax-swap.controller"; + +function createResponse() { + const setHeader = vi.fn(); + const json = vi.fn(); + + return { + response: { + setHeader, + json, + } as unknown as Response, + setHeader, + json, + }; +} + +async function invokeController( + handler: unknown, + req: Request, + res: Response +): Promise> { + const next = vi.fn(); + + ( + handler as ( + req: Request, + res: Response, + next: NextFunction + ) => void + )(req, res, next as unknown as NextFunction); + + await vi.waitFor(() => { + expect( + next.mock.calls.length > 0 || + (res.json as unknown as ReturnType).mock.calls.length > 0 + ).toBe(true); + }); + + return next; +} + +describe("user estate administrative evidence export controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exports the complete user estate for an authenticated Phase 2 evidence administrator", async () => { + const evidence = { + schemaVersion: "1.0", + evidenceType: "panoramablock-user-identity-wallet-evidence", + summary: { + profileCount: 3, + walletCount: 4, + userCount: 4, + }, + }; + + mocks.isPhase2EvidenceAdmin.mockReturnValue(true); + mocks.exportUserIdentityEvidenceAdmin.mockResolvedValue(evidence); + + const req = { + verifiedAddress: + "0x1111111111111111111111111111111111111111", + } as unknown as Request; + + const { response, setHeader, json } = createResponse(); + + const next = await invokeController( + (controller as Record) + .exportUserEstateEvidenceAdmin, + req, + response + ); + + expect(next).not.toHaveBeenCalled(); + + expect(mocks.isPhase2EvidenceAdmin).toHaveBeenCalledWith( + "0x1111111111111111111111111111111111111111" + ); + + expect( + mocks.exportUserIdentityEvidenceAdmin + ).toHaveBeenCalledTimes(1); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Type", + "application/json" + ); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Disposition", + 'attachment; filename="panoramablock-user-estate-evidence.json"' + ); + + expect(json).toHaveBeenCalledWith(evidence); + }); + + it("refuses the user estate export when the authenticated wallet is not a Phase 2 evidence administrator", async () => { + mocks.isPhase2EvidenceAdmin.mockReturnValue(false); + + const req = { + verifiedAddress: + "0x2222222222222222222222222222222222222222", + } as unknown as Request; + + const { response } = createResponse(); + + const next = await invokeController( + (controller as Record) + .exportUserEstateEvidenceAdmin, + req, + response + ); + + expect(mocks.isPhase2EvidenceAdmin).toHaveBeenCalledWith( + "0x2222222222222222222222222222222222222222" + ); + + expect( + mocks.exportUserIdentityEvidenceAdmin + ).not.toHaveBeenCalled(); + + expect(next).toHaveBeenCalledTimes(1); + + const error = next.mock.calls[0][0]; + + expect(error).toMatchObject({ + code: "PHASE2_ADMIN_FORBIDDEN", + status: 403, + }); + }); +}); diff --git a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts index 56372ae..4aff01e 100644 --- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts +++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts @@ -14,6 +14,9 @@ import { recordEvidenceExecutionOutcome } from "../../../shared/services/transaction-evidence.service"; import { AppError } from "../../../shared/errorCodes"; +import { + exportUserIdentityEvidenceAdmin, +} from "../../../shared/services/user-identity-evidence.service"; import { beginAvaxBridgeEvidence, beginAvaxBridgeDestinationEvidence, @@ -443,6 +446,49 @@ export const getEvidenceAdminStatus = asyncHandler( ); +export const exportUserEstateEvidenceAdmin = asyncHandler( + async (req: Request, res: Response) => { + const verifiedAddress = + (req as any).verifiedAddress as string; + + if (!isPhase2EvidenceAdmin(verifiedAddress)) { + throw new AppError("PHASE2_ADMIN_FORBIDDEN"); + } + + try { + const evidenceExport = + await exportUserIdentityEvidenceAdmin(); + + res.setHeader( + "Content-Type", + "application/json" + ); + + res.setHeader( + "Content-Disposition", + `attachment; filename="panoramablock-user-estate-evidence.json"` + ); + + res.json(evidenceExport); + } catch (err) { + if (err instanceof AppError) { + throw err; + } + + const message = + err instanceof Error + ? err.message + : "User estate evidence export failed"; + + throw new AppError( + "INTERNAL_ERROR", + message + ); + } + } +); + + export const exportEvidenceAdmin = asyncHandler( async (req: Request, res: Response) => { const verifiedAddress = diff --git a/backend/src/modules/avax-swap/routes/__tests__/user-estate-evidence-admin.route.test.ts b/backend/src/modules/avax-swap/routes/__tests__/user-estate-evidence-admin.route.test.ts new file mode 100644 index 0000000..b2feb30 --- /dev/null +++ b/backend/src/modules/avax-swap/routes/__tests__/user-estate-evidence-admin.route.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { avaxSwapRoutes } from "../avax-swap.routes"; + +describe("user estate administrative evidence route", () => { + it("registers the authenticated administrative user-estate export endpoint", () => { + const route = (avaxSwapRoutes as any).stack.find( + (layer: any) => + layer.route?.path === + "/evidence/admin/user-estate/export/:userAddress" && + layer.route?.methods?.get === true + ); + + expect(route).toBeDefined(); + + const handlerNames = route.route.stack.map( + (layer: any) => layer.handle?.name + ); + + expect(handlerNames).toContain("requireWalletAuth"); + }); +}); diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts index 49d2f1f..1040e34 100644 --- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts +++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts @@ -219,6 +219,19 @@ avaxSwapRoutes.get( ctrl.exportEvidenceAdmin ); +/** + * GET /avax/swap/evidence/admin/user-estate/export/:userAddress + * Returns an administrative evidence export of the PanoramaBlock user estate. + * Requires wallet signature authentication and Phase 2 admin allowlist membership. + */ +avaxSwapRoutes.get( + "/evidence/admin/user-estate/export/:userAddress", + validateAddress("userAddress", "params"), + requireWalletAuth, + executionTimeout(), + ctrl.exportUserEstateEvidenceAdmin +); + /** * GET /avax/swap/evidence/:correlationId * Returns the complete durable evidence chain from the DB. diff --git a/backend/src/shared/services/__tests__/user-identity-evidence-builder.test.ts b/backend/src/shared/services/__tests__/user-identity-evidence-builder.test.ts new file mode 100644 index 0000000..cee21f7 --- /dev/null +++ b/backend/src/shared/services/__tests__/user-identity-evidence-builder.test.ts @@ -0,0 +1,891 @@ +import { describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { buildUserIdentityEvidence } from '../user-identity-evidence.service'; + +const canonicaliseForVerification = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonicaliseForVerification); + } + + if ( + value !== null && + typeof value === 'object' + ) { + return Object.keys(value) + .sort() + .reduce>( + (canonical, key) => { + canonical[key] = canonicaliseForVerification( + (value as Record)[key] + ); + return canonical; + }, + {} + ); + } + + return value; +}; + +const canonicalStringifyForVerification = ( + value: unknown +): string => + JSON.stringify(canonicaliseForVerification(value)); + +describe('user identity evidence', () => { + it('correlates a user profile to its canonical wallet and user records', () => { + const profile = { + id: '11111111-1111-4111-8111-111111111111', + walletAddress: '0x73FE164B67193E564B630B7925158EB0F9021303', + nickname: 'Evidence User', + investorType: 'moderate', + goals: ['yield'], + preferredChains: ['avalanche'], + riskTolerance: 5, + metadata: {}, + tenantId: 'panorama', + createdAt: '2026-09-18T10:00:00.000Z', + updatedAt: '2026-09-18T10:05:00.000Z' + }; + + const user = { + userId: 'user-001', + walletAddress: '0x73fe164b67193e564b630b7925158eb0f9021303', + displayName: 'Evidence User', + attributes: {}, + tenantId: 'panorama', + createdAt: '2026-09-18T09:00:00.000Z', + lastSeenAt: '2026-09-18T10:04:00.000Z' + }; + + const wallet = { + id: '22222222-2222-4222-8222-222222222222', + userId: 'user-001', + chain: 'AVALANCHE', + address: '0x73fe164b67193e564b630b7925158eb0f9021303', + walletType: 'smart_wallet', + name: 'Primary wallet', + isPrimary: true, + isActive: true, + metadata: { + provider: 'thirdweb' + }, + tenantId: 'panorama', + createdAt: '2026-09-18T09:01:00.000Z', + updatedAt: '2026-09-18T10:03:00.000Z' + }; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users: [user], + wallets: [wallet] + }); + + expect(evidence.records).toHaveLength(1); + + const record = evidence.records[0]; + + expect(record.profile).toEqual(profile); + expect(record.user).toEqual(user); + expect(record.wallets).toEqual([wallet]); + + expect(record.validation).toEqual({ + profileWalletAddress: + '0x73fe164b67193e564b630b7925158eb0f9021303', + matchedWalletIds: [ + '22222222-2222-4222-8222-222222222222' + ], + matchedUserId: 'user-001', + matchedUserIds: [ + 'user-001' + ], + checks: { + profileHasWalletAddress: true, + profileWalletExistsInWalletDatabase: true, + walletReferencesUser: true, + referencedUserExists: true, + identityUniquelyResolved: true, + tenantRelationshipConsistent: true + } + }); + }); +}); + +describe('user identity evidence validation failures', () => { + const profile = { + id: '33333333-3333-4333-8333-333333333333', + walletAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + tenantId: 'panorama', + createdAt: '2026-09-18T11:00:00.000Z', + updatedAt: '2026-09-18T11:00:00.000Z' + }; + + const user = { + userId: 'user-002', + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tenantId: 'panorama', + createdAt: '2026-09-18T10:00:00.000Z' + }; + + const wallet = { + id: '44444444-4444-4444-8444-444444444444', + userId: 'user-002', + chain: 'AVALANCHE', + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + walletType: 'smart_wallet', + isPrimary: true, + isActive: true, + tenantId: 'panorama', + createdAt: '2026-09-18T10:01:00.000Z', + updatedAt: '2026-09-18T10:01:00.000Z' + }; + + it('preserves a profile with no matching canonical wallet and reports the broken relationship', () => { + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users: [user], + wallets: [] + }); + + expect(evidence.records).toHaveLength(1); + + expect(evidence.records[0]).toMatchObject({ + profile, + user: null, + wallets: [], + validation: { + profileWalletAddress: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + matchedWalletIds: [], + matchedUserId: null, + checks: { + profileHasWalletAddress: true, + profileWalletExistsInWalletDatabase: false, + walletReferencesUser: false, + referencedUserExists: false, + tenantRelationshipConsistent: false + } + } + }); + }); + + it('reports a wallet whose referenced canonical user does not exist', () => { + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users: [], + wallets: [wallet] + }); + + expect(evidence.records[0]).toMatchObject({ + profile, + user: null, + wallets: [wallet], + validation: { + matchedWalletIds: [wallet.id], + matchedUserId: null, + checks: { + profileHasWalletAddress: true, + profileWalletExistsInWalletDatabase: true, + walletReferencesUser: true, + referencedUserExists: false, + tenantRelationshipConsistent: false + } + } + }); + }); + + it('reports tenant inconsistency instead of hiding an otherwise matching relationship', () => { + const foreignTenantWallet = { + ...wallet, + tenantId: 'other-tenant' + }; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users: [user], + wallets: [foreignTenantWallet] + }); + + expect(evidence.records[0]).toMatchObject({ + profile, + user, + wallets: [foreignTenantWallet], + validation: { + matchedWalletIds: [foreignTenantWallet.id], + matchedUserId: user.userId, + checks: { + profileHasWalletAddress: true, + profileWalletExistsInWalletDatabase: true, + walletReferencesUser: true, + referencedUserExists: true, + tenantRelationshipConsistent: false + } + } + }); + }); + + it('correlates wallet addresses case-insensitively', () => { + const mixedCaseProfile = { + ...profile, + walletAddress: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' + }; + + const evidence = buildUserIdentityEvidence({ + profiles: [mixedCaseProfile], + users: [user], + wallets: [wallet] + }); + + expect(evidence.records[0].wallets).toEqual([wallet]); + expect( + evidence.records[0].validation.profileWalletAddress + ).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect( + evidence.records[0].validation.checks + .profileWalletExistsInWalletDatabase + ).toBe(true); + }); +}); + +describe('user identity evidence ambiguity', () => { + it('explicitly reports when one profile wallet address resolves to multiple users', () => { + const profile = { + id: '55555555-5555-4555-8555-555555555555', + walletAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + tenantId: 'panorama', + createdAt: '2026-09-18T12:00:00.000Z', + updatedAt: '2026-09-18T12:00:00.000Z' + }; + + const walletOne = { + id: '66666666-6666-4666-8666-666666666666', + userId: 'user-003', + chain: 'AVALANCHE', + address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + walletType: 'smart_wallet', + isPrimary: true, + isActive: true, + tenantId: 'panorama', + createdAt: '2026-09-18T11:00:00.000Z', + updatedAt: '2026-09-18T11:00:00.000Z' + }; + + const walletTwo = { + id: '77777777-7777-4777-8777-777777777777', + userId: 'user-004', + chain: 'BASE', + address: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + walletType: 'evm', + isPrimary: false, + isActive: true, + tenantId: 'panorama', + createdAt: '2026-09-18T11:01:00.000Z', + updatedAt: '2026-09-18T11:01:00.000Z' + }; + + const users = [ + { + userId: 'user-003', + tenantId: 'panorama', + createdAt: '2026-09-18T10:00:00.000Z' + }, + { + userId: 'user-004', + tenantId: 'panorama', + createdAt: '2026-09-18T10:01:00.000Z' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users, + wallets: [walletOne, walletTwo] + }); + + const record = evidence.records[0]; + + expect(record.profile).toEqual(profile); + expect(record.user).toBeNull(); + expect(record.wallets).toEqual([walletOne, walletTwo]); + + expect(record.validation).toEqual({ + profileWalletAddress: + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + matchedWalletIds: [ + walletOne.id, + walletTwo.id + ], + matchedUserId: null, + matchedUserIds: [ + 'user-003', + 'user-004' + ], + checks: { + profileHasWalletAddress: true, + profileWalletExistsInWalletDatabase: true, + walletReferencesUser: true, + referencedUserExists: true, + identityUniquelyResolved: false, + tenantRelationshipConsistent: false + } + }); + }); +}); + +describe('user identity evidence export package', () => { + it('builds a deterministic auditable package with explicit validation summary', () => { + const profiles = [ + { + id: '99999999-9999-4999-8999-999999999999', + walletAddress: '0xdddddddddddddddddddddddddddddddddddddddd', + tenantId: 'panorama', + createdAt: '2026-09-18T13:01:00.000Z', + updatedAt: '2026-09-18T13:01:00.000Z' + }, + { + id: '88888888-8888-4888-8888-888888888888', + walletAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + tenantId: 'panorama', + createdAt: '2026-09-18T13:00:00.000Z', + updatedAt: '2026-09-18T13:00:00.000Z' + } + ]; + + const users = [ + { + userId: 'user-005', + tenantId: 'panorama', + createdAt: '2026-09-18T12:00:00.000Z' + } + ]; + + const wallets = [ + { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + userId: 'user-005', + chain: 'AVALANCHE', + address: '0xcccccccccccccccccccccccccccccccccccccccc', + walletType: 'smart_wallet', + isPrimary: true, + isActive: true, + tenantId: 'panorama', + createdAt: '2026-09-18T12:01:00.000Z', + updatedAt: '2026-09-18T12:01:00.000Z' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles, + users, + wallets + }); + + expect(evidence).toEqual({ + schemaVersion: '1.0', + evidenceType: 'panoramablock-user-identity-wallet-evidence', + summary: { + profileCount: 2, + walletCount: 1, + userCount: 1, + walletWithoutProfileCount: 0, + userWithoutWalletCount: 0, + walletWithoutUserCount: 0, + uniquelyResolvedCount: 1, + unresolvedCount: 1, + ambiguousCount: 0, + tenantInconsistentCount: 0 + }, + sourcePopulation: { + profiles: [ + profiles[1], + profiles[0] + ], + wallets, + users + }, + records: [ + expect.objectContaining({ + profile: expect.objectContaining({ + id: '88888888-8888-4888-8888-888888888888' + }) + }), + expect.objectContaining({ + profile: expect.objectContaining({ + id: '99999999-9999-4999-8999-999999999999' + }) + }) + ], + unmatchedWallets: [], + usersWithoutWallets: [], + walletsWithoutUsers: [], + integrity: { + algorithm: 'sha256', + canonicalisation: 'panoramablock-json-canonical-v1', + payloadHash: expect.stringMatching(/^[0-9a-f]{64}$/) + } + }); + + const { integrity, ...payload } = evidence; + + expect(integrity.payloadHash).toBe( + createHash('sha256') + .update(canonicalStringifyForVerification(payload)) + .digest('hex') + ); + }); + + it('produces the same evidence package regardless of input ordering', () => { + const profiles = [ + { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + walletAddress: '0xffffffffffffffffffffffffffffffffffffffff', + tenantId: 'panorama' + }, + { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + walletAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + tenantId: 'panorama' + } + ]; + + const forward = buildUserIdentityEvidence({ + profiles, + users: [], + wallets: [] + }); + + const reversed = buildUserIdentityEvidence({ + profiles: [...profiles].reverse(), + users: [], + wallets: [] + }); + + expect(forward).toEqual(reversed); + expect(forward.records.map((record) => record.profile.id)).toEqual([ + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ]); + }); +}); + +describe('user identity evidence canonical ordering', () => { + it('canonicalises nested wallet and user references independently of input order', () => { + const profile = { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + walletAddress: '0x1111111111111111111111111111111111111111', + tenantId: 'panorama' + }; + + const users = [ + { + userId: 'user-z', + tenantId: 'panorama' + }, + { + userId: 'user-a', + tenantId: 'panorama' + } + ]; + + const wallets = [ + { + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + userId: 'user-z', + chain: 'BASE', + address: '0x1111111111111111111111111111111111111111', + walletType: 'evm', + isPrimary: false, + isActive: true, + tenantId: 'panorama' + }, + { + id: '11111111-aaaa-4aaa-8aaa-111111111111', + userId: 'user-a', + chain: 'AVALANCHE', + address: '0x1111111111111111111111111111111111111111', + walletType: 'smart_wallet', + isPrimary: true, + isActive: true, + tenantId: 'panorama' + } + ]; + + const forward = buildUserIdentityEvidence({ + profiles: [profile], + users, + wallets + }); + + const reversed = buildUserIdentityEvidence({ + profiles: [profile], + users: [...users].reverse(), + wallets: [...wallets].reverse() + }); + + expect(forward).toEqual(reversed); + + expect( + forward.records[0].wallets.map((wallet) => wallet.id) + ).toEqual([ + '11111111-aaaa-4aaa-8aaa-111111111111', + 'ffffffff-ffff-4fff-8fff-ffffffffffff' + ]); + + expect( + forward.records[0].validation.matchedWalletIds + ).toEqual([ + '11111111-aaaa-4aaa-8aaa-111111111111', + 'ffffffff-ffff-4fff-8fff-ffffffffffff' + ]); + + expect( + forward.records[0].validation.matchedUserIds + ).toEqual([ + 'user-a', + 'user-z' + ]); + }); +}); + +describe('user identity evidence integrity', () => { + it('includes a reproducible SHA-256 digest of the canonical evidence payload', () => { + const profile = { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + walletAddress: '0x2222222222222222222222222222222222222222', + tenantId: 'panorama' + }; + + const user = { + userId: 'user-integrity', + tenantId: 'panorama' + }; + + const wallet = { + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + userId: 'user-integrity', + address: '0x2222222222222222222222222222222222222222', + tenantId: 'panorama' + }; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users: [user], + wallets: [wallet] + }); + + const { integrity, ...payload } = evidence; + + const expectedDigest = createHash('sha256') + .update(canonicalStringifyForVerification(payload)) + .digest('hex'); + + expect(integrity).toEqual({ + algorithm: 'sha256', + canonicalisation: 'panoramablock-json-canonical-v1', + payloadHash: expectedDigest + }); + + const reordered = buildUserIdentityEvidence({ + profiles: [profile], + users: [user], + wallets: [wallet] + }); + + expect(reordered.integrity).toEqual(integrity); + }); +}); + +describe('user identity evidence canonical serialisation', () => { + it('produces the same integrity hash regardless of source object key insertion order', () => { + const profileForward = { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + walletAddress: '0x3333333333333333333333333333333333333333', + nickname: 'Canonical User', + metadata: { + source: 'thirdweb', + environment: 'production' + }, + tenantId: 'panorama' + }; + + const profileReordered = { + tenantId: 'panorama', + metadata: { + environment: 'production', + source: 'thirdweb' + }, + nickname: 'Canonical User', + walletAddress: '0x3333333333333333333333333333333333333333', + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' + }; + + const userForward = { + userId: 'user-canonical', + displayName: 'Canonical User', + attributes: { + provider: 'thirdweb', + status: 'active' + }, + tenantId: 'panorama' + }; + + const userReordered = { + tenantId: 'panorama', + attributes: { + status: 'active', + provider: 'thirdweb' + }, + displayName: 'Canonical User', + userId: 'user-canonical' + }; + + const walletForward = { + id: 'eeeeeeee-1111-4111-8111-eeeeeeeeeeee', + userId: 'user-canonical', + chain: 'AVALANCHE', + address: '0x3333333333333333333333333333333333333333', + walletType: 'smart_wallet', + metadata: { + provider: 'thirdweb', + accountType: 'embedded' + }, + tenantId: 'panorama' + }; + + const walletReordered = { + tenantId: 'panorama', + metadata: { + accountType: 'embedded', + provider: 'thirdweb' + }, + walletType: 'smart_wallet', + address: '0x3333333333333333333333333333333333333333', + chain: 'AVALANCHE', + userId: 'user-canonical', + id: 'eeeeeeee-1111-4111-8111-eeeeeeeeeeee' + }; + + const forward = buildUserIdentityEvidence({ + profiles: [profileForward], + users: [userForward], + wallets: [walletForward] + }); + + const reordered = buildUserIdentityEvidence({ + profiles: [profileReordered], + users: [userReordered], + wallets: [walletReordered] + }); + + expect(forward.integrity.payloadHash).toBe( + reordered.integrity.payloadHash + ); + }); +}); + +describe('user identity evidence estate completeness', () => { + it('explicitly accounts for a canonical wallet that has no matching user profile', () => { + const profile = { + id: 'profile-linked', + walletAddress: '0x1111111111111111111111111111111111111111', + tenantId: 'panorama' + }; + + const users = [ + { + userId: 'user-linked', + tenantId: 'panorama' + }, + { + userId: 'user-without-profile', + tenantId: 'panorama' + } + ]; + + const wallets = [ + { + id: 'wallet-linked', + userId: 'user-linked', + address: '0x1111111111111111111111111111111111111111', + tenantId: 'panorama' + }, + { + id: 'wallet-without-profile', + userId: 'user-without-profile', + address: '0x2222222222222222222222222222222222222222', + tenantId: 'panorama' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users, + wallets + }); + + expect(evidence.summary).toMatchObject({ + profileCount: 1, + walletCount: 2, + userCount: 2, + walletWithoutProfileCount: 1 + }); + + expect(evidence.unmatchedWallets).toEqual([ + wallets[1] + ]); + }); +}); + +describe('user identity evidence user completeness', () => { + it('explicitly accounts for a canonical user that is not referenced by any wallet', () => { + const profile = { + id: 'profile-linked-user', + walletAddress: '0x3333333333333333333333333333333333333333', + tenantId: 'panorama' + }; + + const users = [ + { + userId: 'user-linked-wallet', + tenantId: 'panorama' + }, + { + userId: 'user-without-wallet', + tenantId: 'panorama' + } + ]; + + const wallets = [ + { + id: 'wallet-linked-user', + userId: 'user-linked-wallet', + address: '0x3333333333333333333333333333333333333333', + tenantId: 'panorama' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles: [profile], + users, + wallets + }); + + expect(evidence.summary).toMatchObject({ + profileCount: 1, + walletCount: 1, + userCount: 2, + userWithoutWalletCount: 1 + }); + + expect(evidence.usersWithoutWallets).toEqual([ + users[1] + ]); + }); +}); + +describe('user identity evidence wallet-to-user completeness', () => { + it('explicitly accounts for a canonical wallet whose referenced user does not exist', () => { + const profiles = [ + { + id: 'profile-valid', + walletAddress: '0x4444444444444444444444444444444444444444', + tenantId: 'panorama' + } + ]; + + const users = [ + { + userId: 'user-valid', + tenantId: 'panorama' + } + ]; + + const wallets = [ + { + id: 'wallet-valid', + userId: 'user-valid', + address: '0x4444444444444444444444444444444444444444', + tenantId: 'panorama' + }, + { + id: 'wallet-missing-user', + userId: 'user-does-not-exist', + address: '0x5555555555555555555555555555555555555555', + tenantId: 'panorama' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles, + users, + wallets + }); + + expect(evidence.summary).toMatchObject({ + profileCount: 1, + walletCount: 2, + userCount: 1, + walletWithoutUserCount: 1 + }); + + expect(evidence.walletsWithoutUsers).toEqual([ + wallets[1] + ]); + }); +}); + +describe('user identity evidence source population completeness', () => { + it('includes every retrieved source record even when it is not reachable through a profile identity record', () => { + const profiles = [ + { + id: 'profile-linked', + walletAddress: '0x6666666666666666666666666666666666666666', + tenantId: 'panorama' + } + ]; + + const wallets = [ + { + id: 'wallet-linked', + userId: 'user-linked', + address: '0x6666666666666666666666666666666666666666', + tenantId: 'panorama' + }, + { + id: 'wallet-no-profile', + userId: 'user-no-profile', + address: '0x7777777777777777777777777777777777777777', + tenantId: 'panorama' + } + ]; + + const users = [ + { + userId: 'user-linked', + tenantId: 'panorama' + }, + { + userId: 'user-no-profile', + tenantId: 'panorama' + } + ]; + + const evidence = buildUserIdentityEvidence({ + profiles, + wallets, + users + }); + + expect(evidence.sourcePopulation).toEqual({ + profiles, + wallets, + users + }); + + expect(evidence.summary).toMatchObject({ + profileCount: evidence.sourcePopulation.profiles.length, + walletCount: evidence.sourcePopulation.wallets.length, + userCount: evidence.sourcePopulation.users.length + }); + }); +}); diff --git a/backend/src/shared/services/__tests__/user-identity-evidence.service.test.ts b/backend/src/shared/services/__tests__/user-identity-evidence.service.test.ts new file mode 100644 index 0000000..8557785 --- /dev/null +++ b/backend/src/shared/services/__tests__/user-identity-evidence.service.test.ts @@ -0,0 +1,380 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + exportUserIdentityEvidenceAdmin, +} from "../user-identity-evidence.service"; + +const response = (data: unknown) => + Promise.resolve({ + ok: true, + status: 200, + text: async () => JSON.stringify(data), + } as Response); + +describe("user identity evidence database export", () => { + beforeEach(() => { + process.env.DB_GATEWAY_URL = "http://gateway.test"; + process.env.DB_GATEWAY_SERVICE_TOKEN = "test-token"; + process.env.DB_GATEWAY_TENANT_ID = "panorama-test"; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("retrieves every page of user profiles, wallets, and users before building the evidence export", async () => { + const profilePageOne = Array.from( + { length: 1000 }, + (_, index) => ({ + id: `profile-${String(index).padStart(4, "0")}`, + walletAddress: + "0x1111111111111111111111111111111111111111", + tenantId: "panorama-test", + }) + ); + + const profilePageTwo = [ + { + id: "profile-1000", + walletAddress: + "0x2222222222222222222222222222222222222222", + tenantId: "panorama-test", + }, + ]; + + const wallets = [ + { + id: "wallet-001", + userId: "user-001", + address: + "0x1111111111111111111111111111111111111111", + tenantId: "panorama-test", + }, + ]; + + const users = [ + { + userId: "user-001", + tenantId: "panorama-test", + }, + ]; + + const fetchMock = vi.fn( + async ( + input: string | URL | Request, + _init?: RequestInit + ) => { + const url = new URL(String(input)); + + if (url.pathname === "/v1/user-profiles") { + const skip = url.searchParams.get("skip"); + + if (skip === "0") { + return response({ + data: profilePageOne, + }); + } + + if (skip === "1000") { + return response({ + data: profilePageTwo, + }); + } + } + + if (url.pathname === "/v1/wallets") { + return response({ + data: wallets, + }); + } + + if (url.pathname === "/v1/users") { + return response({ + data: users, + }); + } + + throw new Error(`Unexpected request: ${url.toString()}`); + } + ); + + vi.stubGlobal("fetch", fetchMock); + + const exported = + await exportUserIdentityEvidenceAdmin(); + + expect(exported.summary.profileCount).toBe(1001); + + const requestedUrls = fetchMock.mock.calls.map( + ([input]) => new URL(String(input)) + ); + + expect( + requestedUrls.some( + (url) => + url.pathname === "/v1/user-profiles" && + url.searchParams.get("take") === "1000" && + url.searchParams.get("skip") === "0" + ) + ).toBe(true); + + expect( + requestedUrls.some( + (url) => + url.pathname === "/v1/user-profiles" && + url.searchParams.get("take") === "1000" && + url.searchParams.get("skip") === "1000" + ) + ).toBe(true); + + for (const entity of [ + "user-profiles", + "wallets", + "users", + ]) { + expect( + requestedUrls.some( + (url) => + url.pathname === `/v1/${entity}` && + url.searchParams.get("take") === "1000" && + url.searchParams.get("skip") === "0" + ) + ).toBe(true); + } + + for (const [, options] of fetchMock.mock.calls) { + expect(options).toEqual( + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "X-Tenant-Id": "panorama-test", + }), + }) + ); + } + }); +}); + +describe("user identity evidence complete population retrieval", () => { + it("continues pagination independently for profiles, wallets, and users until each population returns a short page", async () => { + const fullPage = (entity: string) => + Array.from({ length: 1000 }, (_, index) => { + if (entity === "user-profiles") { + return { + id: `profile-${index}`, + walletAddress: + "0x1111111111111111111111111111111111111111", + tenantId: "panorama-test", + }; + } + + if (entity === "wallets") { + return { + id: `wallet-${index}`, + userId: `user-${index}`, + address: + "0x1111111111111111111111111111111111111111", + tenantId: "panorama-test", + }; + } + + return { + userId: `user-${index}`, + tenantId: "panorama-test", + }; + }); + + const fetchMock = vi.fn( + async (input: string | URL | Request) => { + const url = new URL(String(input)); + const entity = url.pathname.replace("/v1/", ""); + const skip = url.searchParams.get("skip"); + + if ( + !["user-profiles", "wallets", "users"].includes( + entity + ) + ) { + throw new Error( + `Unexpected request: ${url.toString()}` + ); + } + + if (skip === "0") { + return response({ + data: fullPage(entity), + }); + } + + if (skip === "1000") { + return response({ + data: [], + }); + } + + throw new Error( + `Unexpected pagination request: ${url.toString()}` + ); + } + ); + + vi.stubGlobal("fetch", fetchMock); + + await exportUserIdentityEvidenceAdmin(); + + const requestedUrls = fetchMock.mock.calls.map( + ([input]) => new URL(String(input)) + ); + + const expectedSelects: Record = { + "user-profiles": [ + "id", + "walletAddress", + "tenantId", + "createdAt", + "updatedAt", + ], + wallets: [ + "id", + "userId", + "chain", + "address", + "walletType", + "isPrimary", + "isActive", + "tenantId", + "createdAt", + "updatedAt", + ], + users: [ + "userId", + "walletAddress", + "tenantId", + "createdAt", + "lastSeenAt", + ], + }; + + for (const entity of [ + "user-profiles", + "wallets", + "users", + ]) { + const entityRequests = requestedUrls.filter( + (url) => + url.pathname === `/v1/${entity}` && + url.searchParams.get("take") === "1000" + ); + + expect( + entityRequests.map( + (url) => url.searchParams.get("skip") + ) + ).toEqual(["0", "1000"]); + + expect( + entityRequests.map((url) => { + const rawSelect = + url.searchParams.get("select"); + + expect(rawSelect).not.toBeNull(); + + return JSON.parse(rawSelect!); + }) + ).toEqual([ + expectedSelects[entity], + expectedSelects[entity], + ]); + } + }); +}); + +describe("user identity evidence source disclosure minimisation", () => { + it("requests only evidence-relevant fields from each database population", async () => { + const expectedSelects: Record = { + "user-profiles": [ + "id", + "walletAddress", + "tenantId", + "createdAt", + "updatedAt", + ], + wallets: [ + "id", + "userId", + "chain", + "address", + "walletType", + "isPrimary", + "isActive", + "tenantId", + "createdAt", + "updatedAt", + ], + users: [ + "userId", + "walletAddress", + "tenantId", + "createdAt", + "lastSeenAt", + ], + }; + + const fetchMock = vi.fn( + async (input: string | URL | Request) => { + const url = new URL(String(input)); + + if ( + ![ + "/v1/user-profiles", + "/v1/wallets", + "/v1/users", + ].includes(url.pathname) + ) { + throw new Error( + `Unexpected request: ${url.toString()}` + ); + } + + return response({ data: [] }); + } + ); + + vi.stubGlobal("fetch", fetchMock); + + await exportUserIdentityEvidenceAdmin(); + + const requestedUrls = fetchMock.mock.calls.map( + ([input]) => new URL(String(input)) + ); + + for (const [ + entity, + expectedSelect, + ] of Object.entries(expectedSelects)) { + const request = requestedUrls.find( + (url) => url.pathname === `/v1/${entity}` + ); + + expect(request).toBeDefined(); + + const rawSelect = + request!.searchParams.get("select"); + + expect(rawSelect).not.toBeNull(); + + expect(JSON.parse(rawSelect!)).toEqual( + expectedSelect + ); + } + }); +}); diff --git a/backend/src/shared/services/user-identity-evidence.service.ts b/backend/src/shared/services/user-identity-evidence.service.ts new file mode 100644 index 0000000..ff3d6e4 --- /dev/null +++ b/backend/src/shared/services/user-identity-evidence.service.ts @@ -0,0 +1,421 @@ +import { createHash } from "crypto"; + +const DEFAULT_TENANT_ID = "panorama-default"; +const PAGE_SIZE = 1000; + +interface UserProfileRecord { + id: string; + walletAddress: string; + tenantId: string; + [key: string]: unknown; +} + +interface UserRecord { + userId: string; + tenantId: string; + [key: string]: unknown; +} + +interface WalletRecord { + id: string; + userId: string; + address: string; + tenantId: string; + [key: string]: unknown; +} + +interface UserIdentityEvidenceInput { + profiles: UserProfileRecord[]; + users: UserRecord[]; + wallets: WalletRecord[]; +} + +interface GatewayListEnvelope { + data: T[]; + [key: string]: unknown; +} + +function gatewayConfig(): { + url: string; + token: string; + tenantId: string; + timeoutMs: number; +} { + const url = process.env.DB_GATEWAY_URL?.replace(/\/+$/, ""); + const token = process.env.DB_GATEWAY_SERVICE_TOKEN; + const tenantId = + process.env.DB_GATEWAY_TENANT_ID?.trim() || DEFAULT_TENANT_ID; + const timeoutMs = Number( + process.env.DB_GATEWAY_TIMEOUT_MS || "5000" + ); + + if (!url) { + throw new Error("DB_GATEWAY_URL is not configured"); + } + + if (!token) { + throw new Error( + "DB_GATEWAY_SERVICE_TOKEN is not configured" + ); + } + + return { + url, + token, + tenantId, + timeoutMs: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : 5000, + }; +} + +async function gatewayList( + path: string +): Promise> { + const config = gatewayConfig(); + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + config.timeoutMs + ); + + try { + const response = await fetch(`${config.url}${path}`, { + method: "GET", + signal: controller.signal, + headers: { + Authorization: `Bearer ${config.token}`, + "X-Tenant-Id": config.tenantId, + }, + }); + + const text = await response.text(); + + if (!response.ok) { + throw new Error( + `Database Gateway ${response.status}: ${text.slice(0, 500)}` + ); + } + + const parsed = text ? JSON.parse(text) : null; + + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + !Array.isArray( + (parsed as GatewayListEnvelope).data + ) + ) { + throw new Error( + "Database Gateway returned an invalid list response" + ); + } + + return parsed as GatewayListEnvelope; + } finally { + clearTimeout(timer); + } +} + +async function listAll( + entity: string, + select: readonly string[] +): Promise { + const records: T[] = []; + let skip = 0; + + while (true) { + const query = new URLSearchParams({ + take: String(PAGE_SIZE), + skip: String(skip), + select: JSON.stringify(select), + }); + + const result = await gatewayList( + `/v1/${entity}?${query.toString()}` + ); + + records.push(...result.data); + + if (result.data.length < PAGE_SIZE) { + break; + } + + skip += PAGE_SIZE; + } + + return records; +} + +const normalizeWalletAddress = ( + address: string +): string => address.trim().toLowerCase(); + +const canonicaliseJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonicaliseJsonValue); + } + + if (value !== null && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce>( + (canonical, key) => { + canonical[key] = canonicaliseJsonValue( + (value as Record)[key] + ); + return canonical; + }, + {} + ); + } + + return value; +}; + +const canonicalStringify = (value: unknown): string => + JSON.stringify(canonicaliseJsonValue(value)); + +export const buildUserIdentityEvidence = ({ + profiles, + users, + wallets, +}: UserIdentityEvidenceInput) => { + const records = profiles.map((profile) => { + const profileWalletAddress = normalizeWalletAddress( + profile.walletAddress + ); + + const matchedWallets = wallets + .filter( + (wallet) => + normalizeWalletAddress(wallet.address) === + profileWalletAddress + ) + .sort((left, right) => + left.id.localeCompare(right.id) + ); + + const referencedUserIds = [ + ...new Set( + matchedWallets.map((wallet) => wallet.userId) + ), + ].sort((left, right) => left.localeCompare(right)); + + const matchedUsers = users.filter((user) => + referencedUserIds.includes(user.userId) + ); + + const referencedUserExists = + referencedUserIds.length > 0 && + referencedUserIds.every((userId) => + matchedUsers.some( + (user) => user.userId === userId + ) + ); + + const identityUniquelyResolved = + referencedUserIds.length === 1 && + matchedUsers.length === 1; + + const matchedUser = + identityUniquelyResolved ? matchedUsers[0] : null; + + const tenantRelationshipConsistent = + matchedWallets.length > 0 && + identityUniquelyResolved && + matchedUser !== null && + matchedWallets.every( + (wallet) => + wallet.tenantId === profile.tenantId && + wallet.tenantId === matchedUser.tenantId + ); + + return { + profile, + user: matchedUser, + wallets: matchedWallets, + validation: { + profileWalletAddress, + matchedWalletIds: matchedWallets.map( + (wallet) => wallet.id + ), + matchedUserId: matchedUser?.userId ?? null, + matchedUserIds: referencedUserIds, + checks: { + profileHasWalletAddress: + profileWalletAddress.length > 0, + profileWalletExistsInWalletDatabase: + matchedWallets.length > 0, + walletReferencesUser: matchedWallets.some( + (wallet) => wallet.userId.length > 0 + ), + referencedUserExists, + identityUniquelyResolved, + tenantRelationshipConsistent, + }, + }, + }; + }); + + records.sort((left, right) => + left.profile.id.localeCompare(right.profile.id) + ); + + const profileWalletAddresses = new Set( + profiles.map((profile) => + normalizeWalletAddress(profile.walletAddress) + ) + ); + + const unmatchedWallets = wallets + .filter( + (wallet) => + !profileWalletAddresses.has( + normalizeWalletAddress(wallet.address) + ) + ) + .sort((left, right) => + left.id.localeCompare(right.id) + ); + + const walletUserIds = new Set( + wallets.map((wallet) => wallet.userId) + ); + + const usersWithoutWallets = users + .filter( + (user) => !walletUserIds.has(user.userId) + ) + .sort((left, right) => + left.userId.localeCompare(right.userId) + ); + + const userIds = new Set( + users.map((user) => user.userId) + ); + + const walletsWithoutUsers = wallets + .filter( + (wallet) => !userIds.has(wallet.userId) + ) + .sort((left, right) => + left.id.localeCompare(right.id) + ); + + const summary = records.reduce( + (counts, record) => { + const { + identityUniquelyResolved, + tenantRelationshipConsistent, + } = record.validation.checks; + + if (record.validation.matchedUserIds.length > 1) { + counts.ambiguousCount += 1; + } else if ( + identityUniquelyResolved && + !tenantRelationshipConsistent + ) { + counts.tenantInconsistentCount += 1; + } else if ( + identityUniquelyResolved && + tenantRelationshipConsistent + ) { + counts.uniquelyResolvedCount += 1; + } else { + counts.unresolvedCount += 1; + } + + return counts; + }, + { + profileCount: records.length, + walletCount: wallets.length, + userCount: users.length, + walletWithoutProfileCount: unmatchedWallets.length, + userWithoutWalletCount: usersWithoutWallets.length, + walletWithoutUserCount: walletsWithoutUsers.length, + uniquelyResolvedCount: 0, + unresolvedCount: 0, + ambiguousCount: 0, + tenantInconsistentCount: 0, + } + ); + + const sourcePopulation = { + profiles: [...profiles].sort((left, right) => + left.id.localeCompare(right.id) + ), + wallets: [...wallets].sort((left, right) => + left.id.localeCompare(right.id) + ), + users: [...users].sort((left, right) => + left.userId.localeCompare(right.userId) + ), + }; + + const payload = { + schemaVersion: "1.0", + evidenceType: + "panoramablock-user-identity-wallet-evidence", + summary, + sourcePopulation, + records, + unmatchedWallets, + usersWithoutWallets, + walletsWithoutUsers, + }; + + const payloadHash = createHash("sha256") + .update(canonicalStringify(payload)) + .digest("hex"); + + return { + ...payload, + integrity: { + algorithm: "sha256", + canonicalisation: + "panoramablock-json-canonical-v1", + payloadHash, + }, + }; +}; + +export async function exportUserIdentityEvidenceAdmin() { + const [profiles, wallets, users] = await Promise.all([ + listAll("user-profiles", [ + "id", + "walletAddress", + "tenantId", + "createdAt", + "updatedAt", + ]), + listAll("wallets", [ + "id", + "userId", + "chain", + "address", + "walletType", + "isPrimary", + "isActive", + "tenantId", + "createdAt", + "updatedAt", + ]), + listAll("users", [ + "userId", + "walletAddress", + "tenantId", + "createdAt", + "lastSeenAt", + ]), + ]); + + return buildUserIdentityEvidence({ + profiles, + users, + wallets, + }); +}