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
16 changes: 13 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 40 additions & 29 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,50 +16,57 @@ model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt


// Primary wallet address — canonical identifier for the user
walletAddress String @unique

reputation Int @default(0)

// Sybil Resistance
worldcoinVerified Boolean @default(false)
sybilScores SybilScore[]
worldcoinVerified Boolean @default(false)
worldcoinVerifiedAt DateTime?

wallets Wallet[]
wallets Wallet[]
sybilScores SybilScore[]
worldIdVerifications WorldIdVerification[]
}

model Wallet {
id String @id @default(uuid())
address String
chain String
linkedAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])

userId String
user User @relation(fields: [userId], references: [id])

@@unique([address, chain])
// We will enforce "one address -> one user" logic in the service layer
// We will enforce "one address -> one user" logic in the service layer
// to handle multi-chain address reuse scenarios properly.
}

model SybilScore {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

// Component scores (0-1 normalized)
worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1)
walletAgeScore Float @default(0.0) // Based on wallet age
stakingScore Float @default(0.0) // Historical participation in staking
accuracyScore Float @default(0.0) // Claim accuracy from verification history
worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1)
walletAgeScore Float @default(0.0) // Based on wallet age
stakingScore Float @default(0.0) // Historical participation in staking
accuracyScore Float @default(0.0) // Claim accuracy from verification history

// Final composite score (0-1)
compositeScore Float @default(0.0) // Final Sybil resistance score
compositeScore Float @default(0.0) // Final Sybil resistance score

// Metadata
calculationDetails String? // JSON string for explainability

calculationDetails String? // JSON string for explainability

explanation SybilExplanation?

@@unique([userId, createdAt])
@@index([userId])
@@index([compositeScore])
Expand All @@ -72,20 +79,24 @@ model SybilExplanation {
createdAt DateTime @default(now())

sybilScore SybilScore @relation(fields: [sybilScoreId], references: [id], onDelete: Cascade)
}

model WorldIdVerification {
id String @id @default(uuid())
verifiedAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
nullifierHash String @unique
id String @id @default(uuid())
verifiedAt DateTime @default(now())

userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

nullifierHash String @unique
verificationLevel String
worldcoinAppId String
worldcoinAction String
merkleRoot String?
proof Json?

@@index([userId])
@@index([nullifierHash])

@@map("world_id_verifications")
}
83 changes: 83 additions & 0 deletions src/entities/user.entity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { User } from './user.entity';
import { UserEntity } from '../modules/users/entities/user.entity';

describe('User entity schema sync (BE-203)', () => {
it('canonical User entity and re-exported UserEntity should reference the same class', () => {
expect(UserEntity).toBe(User);
});

it('User entity maps to the "users" table', () => {
const tableMetadata = getMetadataArgsStorage().tables.find(
(t) => t.target === User,
);
expect(tableMetadata?.name).toBe('users');
});

describe('field coverage — TypeORM ↔ Prisma sync', () => {
let columnNames: string[];

beforeAll(() => {
columnNames = getMetadataArgsStorage()
.columns.filter((c) => c.target === User)
.map((c) => c.propertyName as string);
});

// Fields that must exist in both TypeORM entity and Prisma User model
const requiredFields = [
'id',
'walletAddress',
'reputation',
'worldcoinVerified',
'worldcoinVerifiedAt',
'createdAt',
'updatedAt',
];

for (const field of requiredFields) {
it(`should declare column "${field}"`, () => {
expect(columnNames).toContain(field);
});
}

it('walletAddress should be marked unique', () => {
const col = getMetadataArgsStorage().columns.find(
(c) => c.target === User && c.propertyName === 'walletAddress',
);
expect(col?.options?.unique).toBe(true);
});

it('worldcoinVerified should default to false', () => {
const col = getMetadataArgsStorage().columns.find(
(c) => c.target === User && c.propertyName === 'worldcoinVerified',
);
expect(col?.options?.default).toBe(false);
});

it('worldcoinVerifiedAt should be nullable', () => {
const col = getMetadataArgsStorage().columns.find(
(c) => c.target === User && c.propertyName === 'worldcoinVerifiedAt',
);
expect(col?.options?.nullable).toBe(true);
});

it('reputation should default to 0', () => {
const col = getMetadataArgsStorage().columns.find(
(c) => c.target === User && c.propertyName === 'reputation',
);
expect(col?.options?.default).toBe(0);
});
});

describe('relation coverage', () => {
it('User entity has a wallets OneToMany relation', () => {
const relations = getMetadataArgsStorage().relations.filter(
(r) => r.target === User,
);
const walletsRelation = relations.find((r) => r.propertyName === 'wallets');
expect(walletsRelation).toBeDefined();
expect(walletsRelation?.relationType).toBe('one-to-many');
});
});
});
29 changes: 7 additions & 22 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,25 @@ import {
Index,
} from 'typeorm';

/**
* User Entity
*
* Represents a verified user in the TruthBounty protocol.
* Users can link multiple wallets across different chains.
* Reputation is tracked to weight verification votes.
*/
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;

/**
* Primary wallet address for the user
* This is the canonical identifier for the user
*/
@Column({ unique: true })
@Index()
walletAddress: string;

/**
* User's reputation score (0-100)
* Used to weight verification votes
* Increases with accurate verifications, decreases with inaccurate ones
*/
@Column({ type: 'int', default: 0 })
reputation: number;

/**
* All wallets linked to this user across different chains
*/
@OneToMany('Wallet', 'user', {
cascade: true,
})
@Column({ default: false })
worldcoinVerified: boolean;

@Column({ type: 'datetime', nullable: true })
worldcoinVerifiedAt: Date | null;

@OneToMany('Wallet', 'user', { cascade: true })
wallets: any[];

@CreateDateColumn()
Expand Down
15 changes: 15 additions & 0 deletions src/generated/client/internal/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ export interface PrismaClient<
* ```
*/
get sybilScore(): Prisma.SybilScoreDelegate<ExtArgs, { omit: OmitOpts }>;

/**
* `prisma.sybilExplanation`: Exposes CRUD operations for the **SybilExplanation** model.
*/
get sybilExplanation(): any;

/**
* `prisma.worldIdVerification`: Exposes CRUD operations for the **WorldIdVerification** model.
* Example usage:
* ```ts
* // Fetch zero or more WorldIdVerifications
* const verifications = await prisma.worldIdVerification.findMany()
* ```
*/
get worldIdVerification(): any;
}

export function getPrismaClientClass(): PrismaClientConstructor {
Expand Down
Loading
Loading