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
13 changes: 13 additions & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ model CreatorProfile {

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priceSnapshot CreatorPriceSnapshot?
posts CreatorPost[]
}

model CreatorPost {
id String @id @default(cuid())
content String
creatorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade)

@@index([creatorId, createdAt])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE "CreatorPost" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CreatorPost_pkey" PRIMARY KEY ("id"),
CONSTRAINT "CreatorPost_creatorId_fkey"
FOREIGN KEY ("creatorId") REFERENCES "CreatorProfile"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "CreatorPost_creatorId_createdAt_idx"
ON "CreatorPost"("creatorId", "createdAt");
1 change: 1 addition & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const envSchema = z
'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)'
)
.default('https://soroban-testnet.stellar.org'),
STELLAR_AUTH_SECRET: optionalNonEmptyString,

// Ownership snapshot cleanup job
OWNERSHIP_SNAPSHOT_TABLE_NAME: z
Expand Down
2 changes: 2 additions & 0 deletions src/constants/error.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const ErrorCode = {
RATE_LIMIT: 'RATE_LIMIT',
PRISMA_ERROR: 'DATABASE_ERROR',
JWT_ERROR: 'TOKEN_ERROR',
INSUFFICIENT_BALANCE: 'insufficient_balance',
NOT_A_CREATOR: 'not_a_creator',
} as const;

export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
100 changes: 100 additions & 0 deletions src/jobs/key-balance-sync.worker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
runKeyBalanceSync,
type BalanceRecord,
type KeyBalanceSyncDependencies,
} from './key-balance-sync.worker';

function createDependencies(
records: BalanceRecord[],
chainBalances: Record<string, number | null>
) {
const updateBalance = jest.fn().mockResolvedValue(undefined);
const logCorrection = jest.fn();
const dependencies: KeyBalanceSyncDependencies = {
listBalances: jest.fn().mockResolvedValue(records),
getOnChainBalance: jest.fn((owner, creator) =>
Promise.resolve(chainBalances[`${owner}:${creator}`] ?? null)
),
updateBalance,
logCorrection,
};
return { dependencies, updateBalance, logCorrection };
}

describe('key balance sync worker', () => {
it('updates stale balances and logs old and new values', async () => {
const { dependencies, updateBalance, logCorrection } = createDependencies(
[
{
id: '1',
ownerAddress: 'wallet-a',
creatorId: 'creator-a',
balance: 5,
},
],
{ 'wallet-a:creator-a': 8 }
);

await expect(runKeyBalanceSync(dependencies)).resolves.toBe(1);
expect(updateBalance).toHaveBeenCalledWith('1', 8);
expect(logCorrection).toHaveBeenCalledWith(
expect.objectContaining({ oldBalance: 5, newBalance: 8 })
);
});

it('does not write when the database already matches the chain', async () => {
const { dependencies, updateBalance, logCorrection } = createDependencies(
[
{
id: '1',
ownerAddress: 'wallet-a',
creatorId: 'creator-a',
balance: 5,
},
],
{ 'wallet-a:creator-a': 5 }
);
await expect(runKeyBalanceSync(dependencies)).resolves.toBe(0);
expect(updateBalance).not.toHaveBeenCalled();
expect(logCorrection).not.toHaveBeenCalled();
});

it('sets a missing on-chain balance to zero', async () => {
const { dependencies, updateBalance } = createDependencies(
[
{
id: '1',
ownerAddress: 'wallet-a',
creatorId: 'creator-a',
balance: 5,
},
],
{ 'wallet-a:creator-a': null }
);
await runKeyBalanceSync(dependencies);
expect(updateBalance).toHaveBeenCalledWith('1', 0);
});

it('processes multiple wallets independently', async () => {
const { dependencies, updateBalance } = createDependencies(
[
{
id: '1',
ownerAddress: 'wallet-a',
creatorId: 'creator-a',
balance: 1,
},
{
id: '2',
ownerAddress: 'wallet-b',
creatorId: 'creator-b',
balance: 2,
},
],
{ 'wallet-a:creator-a': 3, 'wallet-b:creator-b': 4 }
);
await expect(runKeyBalanceSync(dependencies)).resolves.toBe(2);
expect(updateBalance).toHaveBeenNthCalledWith(1, '1', 3);
expect(updateBalance).toHaveBeenNthCalledWith(2, '2', 4);
});
});
75 changes: 75 additions & 0 deletions src/jobs/key-balance-sync.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { prisma } from '../utils/prisma.utils';
import { logger } from '../utils/logger.utils';

export interface BalanceRecord {
id: string;
ownerAddress: string;
creatorId: string;
balance: { toString(): string } | number;
}

export interface KeyBalanceSyncDependencies {
listBalances(): Promise<BalanceRecord[]>;
getOnChainBalance(
ownerAddress: string,
creatorId: string
): Promise<number | null>;
updateBalance(id: string, balance: number): Promise<void>;
logCorrection(fields: {
ownerAddress: string;
creatorId: string;
oldBalance: number;
newBalance: number;
}): void;
}

export async function runKeyBalanceSync(
dependencies: KeyBalanceSyncDependencies
): Promise<number> {
const records = await dependencies.listBalances();
let corrected = 0;

for (const record of records) {
const oldBalance = Number(record.balance.toString());
const newBalance =
(await dependencies.getOnChainBalance(
record.ownerAddress,
record.creatorId
)) ?? 0;
if (oldBalance === newBalance) continue;

await dependencies.updateBalance(record.id, newBalance);
dependencies.logCorrection({
ownerAddress: record.ownerAddress,
creatorId: record.creatorId,
oldBalance,
newBalance,
});
corrected += 1;
}

return corrected;
}

export interface OnChainKeyBalanceReader {
getBalance(ownerAddress: string, creatorId: string): Promise<number | null>;
}

export async function syncKeyBalances(
reader: OnChainKeyBalanceReader
): Promise<number> {
return runKeyBalanceSync({
listBalances: () => prisma.keyOwnership.findMany(),
getOnChainBalance: (ownerAddress, creatorId) =>
reader.getBalance(ownerAddress, creatorId),
updateBalance: async (id, balance) => {
await prisma.keyOwnership.update({
where: { id },
data: { balance },
});
},
logCorrection: fields => {
logger.info(fields, 'Corrected stale key balance from on-chain state');
},
});
}
2 changes: 2 additions & 0 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
httpRegisterUserWithPassword,
httpRefreshToken,
} from './auth.controllers';
import { httpStellarChallenge } from './stellar-challenge.controller';

const authRouter = Router();

authRouter.post('/challenge', httpStellarChallenge);
authRouter.post('/login', httpLogin);
authRouter.post('/register', httpRegisterUserWithPassword);
authRouter.post('/refresh', httpRefreshToken);
Expand Down
66 changes: 66 additions & 0 deletions src/modules/auth/stellar-challenge.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Request, Response } from 'express';
import {
Account,
Keypair,
Memo,
Networks,
Operation,
TransactionBuilder,
} from '@stellar/stellar-base';
import { randomBytes } from 'crypto';
import { envConfig } from '../../config';
import { StellarAddressSchema } from '../wallet/wallet.schemas';
import { ErrorCode } from '../../constants/error.constants';
import { sendError, sendSuccess } from '../../utils/api-response.utils';

const serverKeypair = envConfig.STELLAR_AUTH_SECRET
? Keypair.fromSecret(envConfig.STELLAR_AUTH_SECRET)
: Keypair.random();

export function getChallengeServerPublicKey(): string {
return serverKeypair.publicKey();
}

export async function httpStellarChallenge(
req: Request,
res: Response
): Promise<void> {
const walletAddress = req.body?.wallet_address;
if (!StellarAddressSchema.safeParse(walletAddress).success) {
sendError(
res,
422,
ErrorCode.VALIDATION_ERROR,
'A valid Stellar wallet_address is required'
);
return;
}

const nonce = randomBytes(12).toString('hex');
const networkPassphrase =
envConfig.STELLAR_NETWORK === 'mainnet'
? Networks.PUBLIC
: Networks.TESTNET;
const webAuthDomain = new URL(envConfig.BACKEND_URL).host;
const transaction = new TransactionBuilder(
new Account(walletAddress, '-1'),
{ fee: '0', networkPassphrase }
)
.addOperation(
Operation.manageData({
name: 'web_auth_domain',
value: webAuthDomain,
source: serverKeypair.publicKey(),
})
)
.addMemo(Memo.text(nonce))
.setTimeout(300)
.build();
transaction.sign(serverKeypair);

sendSuccess(res, {
transaction: transaction.toXDR(),
server_public_key: serverKeypair.publicKey(),
network_passphrase: networkPassphrase,
});
}
65 changes: 65 additions & 0 deletions src/modules/auth/stellar-challenge.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import express from 'express';
import request from 'supertest';
import {
Keypair,
Networks,
Transaction,
TransactionBuilder,
} from '@stellar/stellar-base';
import {
getChallengeServerPublicKey,
httpStellarChallenge,
} from './stellar-challenge.controller';

const app = express();
app.use(express.json());
app.post('/api/v1/auth/challenge', httpStellarChallenge);

describe('wallet authentication challenge', () => {
it('returns a signed XDR with web_auth_domain and a unique nonce memo', async () => {
const walletAddress = Keypair.random().publicKey();
const first = await request(app)
.post('/api/v1/auth/challenge')
.send({ wallet_address: walletAddress });
const second = await request(app)
.post('/api/v1/auth/challenge')
.send({ wallet_address: walletAddress });

expect(first.status).toBe(200);
const transaction = TransactionBuilder.fromXDR(
first.body.data.transaction,
Networks.TESTNET
) as Transaction;
expect(transaction.operations).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'manageData',
name: 'web_auth_domain',
}),
])
);
expect(transaction.memo.type).toBe('text');
expect(transaction.memo.value).toBeTruthy();
const secondTransaction = TransactionBuilder.fromXDR(
second.body.data.transaction,
Networks.TESTNET
) as Transaction;
expect(secondTransaction.memo.value).not.toBe(transaction.memo.value);

expect(transaction.signatures).toHaveLength(1);
const server = Keypair.fromPublicKey(getChallengeServerPublicKey());
expect(
server.verify(
transaction.hash(),
transaction.signatures[0].signature()
)
).toBe(true);
});

it('returns 422 for an invalid wallet address', async () => {
const response = await request(app)
.post('/api/v1/auth/challenge')
.send({ wallet_address: 'invalid' });
expect(response.status).toBe(422);
});
});
Loading
Loading