Skip to content
Open
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
5 changes: 2 additions & 3 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
# 2. eval() or spawn() in config files — should never appear there
HITS=$(git diff "origin/$BASE"...HEAD -- \
'*.config.js' '*.config.ts' '*.config.cjs' '*.config.mjs' \
| grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true)
| grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true)
if [[ -n "$HITS" ]]; then
FINDINGS="${FINDINGS}- \`eval()\` or \`spawn()\` found in a config file
"
Expand Down Expand Up @@ -155,8 +155,7 @@ jobs:
'2. Scan your machine for malware immediately',
'3. Rotate your GitHub token and all other credentials stored on that machine',
'4. Recreate your branch from the latest default branch and re-apply only your own changes',
].join('
')
].join('\n')
});

- name: Fail if malware detected
Expand Down
38 changes: 38 additions & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,48 @@ model CreatorProfile {
socialMetadata CreatorSocialMetadata?
socialMetadataEvents SocialMetadataEvent[]

bond KeyPerformanceBond?
bondForfeitures BondForfeiture[]

@@index([userId])
@@index([successorKeyId])
}

/// Performance bond posted by a creator for their key, mirrored from the bond contract.
model KeyPerformanceBond {
id String @id @default(cuid())
creatorId String @unique
creatorAddress String
/// Amount deposited, in XLM with 7 decimals.
amount Decimal @db.Decimal(20, 7)
/// One of: deposited, released, forfeited.
state String @default("deposited")
/// Last ledger at which the bond state was synced from chain.
lastSyncedLedger Int? @map("last_synced_ledger")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

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

@@index([creatorAddress])
}

/// Indexed BondForfeited event, one row per forfeiture.
model BondForfeiture {
id String @id @default(cuid())
creatorId String
amount Decimal @db.Decimal(20, 7)
reason String
ledger Int
txHash String @unique
timestamp DateTime
createdAt DateTime @default(now())

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

@@index([creatorId, timestamp])
}

model CreatorKeyRelaunch {
id String @id @default(cuid())
creatorId String
Expand Down
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// src/app.ts
import express, { Express, Response, RequestHandler } from 'express';
import { TspecDocsMiddleware } from 'tspec';
import { errorHandler, notFoundHandler } from './middlewares/error.middleware'; // Add notFoundHandler import
import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
import router from './modules/index';
import stakingRouter from './modules/staking/staking.routes';
import statusRouter from './modules/status/status.routes';
Expand Down
29 changes: 29 additions & 0 deletions src/clients/soroban.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,32 @@ export async function getTransaction(
): Promise<GetTransactionResult> {
return callRpc<GetTransactionResult>('getTransaction', { hash }, postJson);
}

/** Result of the `simulateTransaction` JSON-RPC method (read-only contract call). */
export interface SimulateTransactionResult {
/** Base64 XDR return value of the invoked contract function. */
results?: Array<{ xdr: string; auth?: string[] }>;
/** Set when the simulation failed on-chain logic. */
error?: string;
latestLedger?: number;
minResourceFee?: string;
}

/**
* Simulate a read-only contract invocation (e.g. `get_bond_status`) without
* submitting a transaction. Used by the bond status service, which caches the
* decoded result for 60s with stale-while-revalidate.
*
* @param transactionXdr - Base64 XDR of an unsigned invoke-host-function envelope.
* @param postJson - Injectable transport (tests pass a stub).
*/
export async function simulateTransaction(
transactionXdr: string,
postJson: SorobanPostJson = defaultPostJson
): Promise<SimulateTransactionResult> {
return callRpc<SimulateTransactionResult>(
'simulateTransaction',
{ transaction: transactionXdr },
postJson
);
}
12 changes: 12 additions & 0 deletions src/constants/creator.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,15 @@ export const BASE = '/creators';

/** Root segment for creator module routes. */
export const ROOT = '/';

/** Route segment for a single key's performance bond resource. */
export const KEY_BOND = '/keys/:key_id/bond';

/** Route segment for a key's bond forfeiture history resource. */
export const KEY_BOND_HISTORY = '/keys/:key_id/bond/history';

/** Cache TTL in seconds for bond state fetched from the get_bond_status contract call. */
export const BOND_STATUS_CACHE_TTL_SECONDS = 60;

/** Route segment for the performance bond status API endpoint. */
export const KEY_BOND_STATUS = '/keys/:key_id/bond/status';
40 changes: 40 additions & 0 deletions src/indexer/event-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// src/indexer/event-processor.ts
// Processes indexed contract events; persists BondForfeited into forfeiture history.

import { prisma } from '../utils/prisma.utils';
import { logger } from '../utils/logger.utils';

export interface BondForfeitedEvent {
keyId: string;
amount: string;
reason: string;
txHash: string;
ledgerTimestamp: Date;
}

/**
* Handles a decoded BondForfeited contract event. Idempotent on txHash so
* re-processing a ledger does not duplicate forfeiture records.
*/
export async function handleBondForfeited(event: BondForfeitedEvent): Promise<void> {
if (!event.txHash) {
throw new Error('BondForfeited event missing txHash');
}

await prisma.bondForfeiture.upsert({
where: { txHash: event.txHash },
create: {
keyId: event.keyId,
amount: event.amount,
reason: event.reason,
timestamp: event.ledgerTimestamp,
txHash: event.txHash,
},
update: {},
});

logger.info(
{ keyId: event.keyId, txHash: event.txHash },
'Indexed BondForfeited event'
);
}
128 changes: 128 additions & 0 deletions src/modules/creator/creator.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// src/modules/creator/creator.controllers.ts
// Performance bond status endpoints for creator keys.

import { Request, Response, NextFunction } from 'express';
import { prisma } from '../../utils/prisma.utils';
import { getBondStatus } from '../../clients/bond.client';

const BOND_CACHE_TTL_MS = 60_000;

interface BondCacheEntry {
value: BondStatusView;
fetchedAt: number;
refreshing: boolean;
}

export interface BondStatusView {
keyId: string;
creator: string;
amount: string;
state: 'deposited' | 'released' | 'forfeited';
}

const bondCache = new Map<string, BondCacheEntry>();

/**
* Returns bond state for a key using a 60s stale-while-revalidate cache.
* Fresh entries are served directly; stale entries are served immediately
* while a background refresh updates the cache.
*/
async function getCachedBondStatus(keyId: string): Promise<BondStatusView | null> {
const cached = bondCache.get(keyId);
const now = Date.now();

if (cached) {
const isStale = now - cached.fetchedAt >= BOND_CACHE_TTL_MS;
if (!isStale) {
return cached.value;
}
if (!cached.refreshing) {
cached.refreshing = true;
refreshBond(keyId).catch(() => {
const entry = bondCache.get(keyId);
if (entry) entry.refreshing = false;
});
}
return cached.value;
}

return refreshBond(keyId);
}

async function refreshBond(keyId: string): Promise<BondStatusView | null> {
const status = await getBondStatus(keyId);
if (!status) {
bondCache.delete(keyId);
return null;
}
bondCache.set(keyId, {
value: status,
fetchedAt: Date.now(),
refreshing: false,
});
return status;
}

/** GET /keys/:key_id/bond */
export async function getKeyBond(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const keyId = req.params.key_id;
const bond = await getCachedBondStatus(keyId);

if (!bond) {
res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', message: 'Bond not found for key' },
});
return;
}

res.json({ success: true, data: bond });
} catch (error) {
next(error);
}
}

/** GET /keys/:key_id/bond/history */
export async function getKeyBondHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const keyId = req.params.key_id;

const bond = await getCachedBondStatus(keyId);
if (!bond) {
res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', message: 'Bond not found for key' },
});
return;
}

const records = await prisma.bondForfeiture.findMany({
where: { keyId },
orderBy: { timestamp: 'desc' },
select: { reason: true, timestamp: true, amount: true, txHash: true },
});

res.json({
success: true,
data: {
items: records.map((r) => ({
reason: r.reason,
timestamp: r.timestamp.toISOString(),
amount: r.amount.toString(),
txHash: r.txHash,
})),
},
});
} catch (error) {
next(error);
}
}
Loading
Loading