Skip to content
Closed
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
8 changes: 7 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,17 @@ NEXT_PUBLIC_MONARCH_API_NEW=https://indexer.monarchlend.xyz/graphql
# Do not use the old NEXT_PUBLIC_MONARCH_API_KEY variable.
NEXT_PUBLIC_MONARCH_PREVIEW_API_KEY=

# Server-only token used by /api/api-keys to create user API keys through the data gateway admin endpoint.
# Server-only token used only by /api/api-keys to create user-facing mk_live/mk_test API keys
# through the data gateway admin endpoint. This is not the data-api internal write key.
MONARCH_API_KEYS_ADMIN_TOKEN=
# Optional override. Defaults to the direct Cloudflare Worker admin endpoint.
MONARCH_API_KEYS_ADMIN_URL=

# Server-only key used by referral and platform-fee app routes when calling data-api /internal/*.
# Server-to-server writes intentionally use the direct data-api origin, not the public Cloudflare API host.
DATA_API_INTERNAL_ORIGIN=
DATA_API_INTERNAL_ADMIN_KEY=

# ==================== Oracle Metadata ====================
# Base URL for oracle metadata Gist (without trailing slash)
# Example: https://gist.githubusercontent.com/username/gist-id/raw
Expand Down
132 changes: 29 additions & 103 deletions app/api/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,28 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createPublicClient, getAddress, http, isAddress, type Address, type Chain } from 'viem';
import { verifyMessage } from 'viem/actions';
import { arbitrum, base, etherlink, hyperEvm, mainnet, monad, optimism, polygon, unichain } from 'viem/chains';
import { parseApiKeyRequestMessage } from '@/utils/apiKeyRequest';
import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks';
import { verifyWalletMessage } from '@/utils/serverWalletSignature';

const DEFAULT_ADMIN_ENDPOINT = 'https://data-api-gateway-worker.antonassocareer.workers.dev/admin/api-keys';
const ADMIN_REQUEST_TIMEOUT_MS = 10_000;
const REQUEST_TTL_MS = 10 * 60 * 1000;
const REQUEST_CLOCK_SKEW_MS = 60 * 1000;
const ADMIN_REQUEST_TIMEOUT_MS = 10_000;
const VERCEL_PREVIEW_HOST_SUFFIX = '.vercel.app';
const FIRST_PARTY_HOSTS = new Set(['monarchlend.xyz', 'www.monarchlend.xyz']);
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const NONCE_PATTERN = /^[A-Za-z0-9-]{16,80}$/;

const VERIFICATION_CHAINS: Record<SupportedNetworks, Chain> = {
[SupportedNetworks.Mainnet]: mainnet,
[SupportedNetworks.Optimism]: optimism,
[SupportedNetworks.Base]: base,
[SupportedNetworks.Polygon]: polygon,
[SupportedNetworks.Unichain]: unichain,
[SupportedNetworks.Arbitrum]: arbitrum,
[SupportedNetworks.Etherlink]: etherlink,
[SupportedNetworks.HyperEVM]: hyperEvm,
[SupportedNetworks.Monad]: monad,
};

const RPC_ENV_BY_CHAIN: Partial<Record<SupportedNetworks, string | undefined>> = {
[SupportedNetworks.Mainnet]: process.env.NEXT_PUBLIC_ETHEREUM_RPC,
[SupportedNetworks.Optimism]: process.env.NEXT_PUBLIC_OPTIMISM_RPC,
[SupportedNetworks.Base]: process.env.NEXT_PUBLIC_BASE_RPC,
[SupportedNetworks.Polygon]: process.env.NEXT_PUBLIC_POLYGON_RPC,
[SupportedNetworks.Unichain]: process.env.NEXT_PUBLIC_UNICHAIN_RPC,
[SupportedNetworks.Arbitrum]: process.env.NEXT_PUBLIC_ARBITRUM_RPC,
[SupportedNetworks.Etherlink]: process.env.NEXT_PUBLIC_ETHERLINK_RPC,
[SupportedNetworks.HyperEVM]: process.env.NEXT_PUBLIC_HYPEREVM_RPC,
[SupportedNetworks.Monad]: process.env.NEXT_PUBLIC_MONAD_RPC,
};

type CreateApiKeyRequestBody = {
interface CreateApiKeyRequestBody {
address?: unknown;
signature?: unknown;
message?: unknown;
name?: unknown;
};
}

type AdminCreateApiKeyResponse = {
interface AdminCreateApiKeyResponse {
apiKey?: unknown;
key?: unknown;
error?: unknown;
};
}

export async function POST(request: NextRequest) {
const adminToken = process.env.MONARCH_API_KEYS_ADMIN_TOKEN?.trim();
Expand All @@ -64,15 +38,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid signature message.' }, { status: 400 });
}

if (!isAddress(body.address) || !isAddress(parsedMessage.wallet)) {
return NextResponse.json({ error: 'Invalid wallet address.' }, { status: 400 });
}

const address = getAddress(body.address);
if (getAddress(parsedMessage.wallet) !== address) {
return NextResponse.json({ error: 'Signed wallet does not match connected wallet.' }, { status: 400 });
}

const applicationOrigin = getApplicationOrigin(request);
if (!applicationOrigin) {
return NextResponse.json({ error: 'Unsupported application origin.' }, { status: 403 });
Expand All @@ -86,35 +51,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Signature request expired.' }, { status: 400 });
}

if (!/^[A-Za-z0-9-]{16,80}$/.test(parsedMessage.nonce)) {
if (!NONCE_PATTERN.test(parsedMessage.nonce)) {
return NextResponse.json({ error: 'Invalid signature nonce.' }, { status: 400 });
}

if (!isSupportedNetwork(parsedMessage.chainId)) {
return NextResponse.json({ error: 'Unsupported signature chain.' }, { status: 400 });
}

let signatureValid: boolean;
try {
signatureValid = await verifyWalletSignature({
address,
chainId: parsedMessage.chainId,
message: body.message,
signature: body.signature,
});
} catch {
return NextResponse.json({ error: 'Failed to verify wallet signature.' }, { status: 502 });
}

if (!signatureValid) {
return NextResponse.json({ error: 'Invalid wallet signature.' }, { status: 401 });
}
const verification = await verifyWalletMessage({
address: body.address,
signedWallet: parsedMessage.wallet,
chainId: parsedMessage.chainId,
signature: body.signature,
message: body.message,
});
if (!verification.ok) return NextResponse.json({ error: verification.error }, { status: verification.status });

const adminResponse = await createGatewayApiKey({
adminToken,
address,
address: verification.address,
name: body.name,
chainId: parsedMessage.chainId,
chainId: verification.chainId,
origin: parsedMessage.origin,
issuedAt: parsedMessage.issuedAt,
nonce: parsedMessage.nonce,
Expand Down Expand Up @@ -151,10 +105,6 @@ async function readCreateApiKeyRequest(request: NextRequest): Promise<
return { error: 'address, signature, and message are required.' };
}

if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(signature)) {
return { error: 'Invalid signature format.' };
}

return {
address,
signature,
Expand Down Expand Up @@ -238,31 +188,20 @@ async function createGatewayApiKey({
);
}

function verifyWalletSignature({
address,
chainId,
message,
signature,
}: {
address: string;
chainId: SupportedNetworks;
message: string;
signature: string;
}) {
const rpcUrl = RPC_ENV_BY_CHAIN[chainId]?.trim() || undefined;
const client = createPublicClient({
chain: VERIFICATION_CHAINS[chainId],
transport: http(rpcUrl),
});
function readRequiredString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

return verifyMessage(client, {
address: address as Address,
message,
signature: signature as `0x${string}`,
});
function sanitizeKeyName(value: unknown): string {
if (typeof value !== 'string') return 'Monarch API key';

const trimmed = value.trim().replace(/\s+/g, ' ');
if (!trimmed) return 'Monarch API key';

return trimmed.slice(0, 120);
}

function getApplicationOrigin(request: NextRequest): string | null {
function getApplicationOrigin(request: { headers: Headers; url: string }): string | null {
const host = readForwardedHeader(request.headers.get('x-forwarded-host')) ?? request.headers.get('host');
if (!host) return null;

Expand All @@ -289,19 +228,6 @@ function isFreshTimestamp(value: string): boolean {
return issuedAtMs <= now + REQUEST_CLOCK_SKEW_MS && now - issuedAtMs <= REQUEST_TTL_MS;
}

function readRequiredString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function sanitizeKeyName(value: unknown): string {
if (typeof value !== 'string') return 'Monarch API key';

const trimmed = value.trim().replace(/\s+/g, ' ');
if (!trimmed) return 'Monarch API key';

return trimmed.slice(0, 120);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
73 changes: 73 additions & 0 deletions app/api/platform-fees/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from 'next/server';
import { isAddress } from 'viem';
import { callDataApiInternal } from '@/utils/dataApiInternal';

const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/;

export async function POST(request: NextRequest) {
let body: unknown;

try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}

if (!isRecord(body)) {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}

const userWallet = readString(body.userWallet);
const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN;
const txHash = readString(body.txHash);
const source = readString(body.source);
const tokenAddress = readString(body.tokenAddress);
const amountRaw = readString(body.amountRaw);

if (
!userWallet ||
!isAddress(userWallet) ||
!Number.isInteger(chainId) ||
!txHash ||
!TX_HASH_PATTERN.test(txHash) ||
!source ||
!tokenAddress ||
!isAddress(tokenAddress) ||
!amountRaw ||
!/^[0-9]+$/.test(amountRaw) ||
BigInt(amountRaw) <= 0n
) {
return NextResponse.json({ error: 'Invalid platform fee request.' }, { status: 400 });
}
Comment on lines +20 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Normalize the wallet, token address, and transaction hash to lowercase to prevent case-sensitivity mismatches in the database. Additionally, validate the length of the source parameter to prevent excessively long strings from being processed.

  const userWallet = readString(body.userWallet)?.toLowerCase();
  const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN;
  const txHash = readString(body.txHash)?.toLowerCase();
  const source = readString(body.source);
  const tokenAddress = readString(body.tokenAddress)?.toLowerCase();
  const amountRaw = readString(body.amountRaw);

  if (
    !userWallet ||
    !isAddress(userWallet) ||
    !Number.isInteger(chainId) ||
    !txHash ||
    !TX_HASH_PATTERN.test(txHash) ||
    !source ||
    source.length > 64 ||
    !tokenAddress ||
    !isAddress(tokenAddress) ||
    !amountRaw ||
    !/^[0-9]+$/.test(amountRaw) ||
    BigInt(amountRaw) <= 0n
  ) {
    return NextResponse.json({ error: 'Invalid platform fee request.' }, { status: 400 });
  }


try {
const response = await callDataApiInternal('/internal/platform-fees', {
userWallet,
chainId,
txHash,
source,
tokenAddress,
amountRaw,
});
const data = (await response.json().catch(() => ({}))) as { error?: unknown };

if (!response.ok) {
return NextResponse.json(
{ error: typeof data.error === 'string' ? data.error : 'Failed to record platform fee.' },
{ status: response.status || 502 },
);
}

return NextResponse.json(data);
} catch {
return NextResponse.json({ error: 'Failed to record platform fee.' }, { status: 500 });
}
}

function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
64 changes: 64 additions & 0 deletions app/api/referrals/attribute/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type NextRequest, NextResponse } from 'next/server';
import { isAddress } from 'viem';
import { callDataApiInternal } from '@/utils/dataApiInternal';
Comment on lines +1 to +3
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import normalizeReferralCode to validate and normalize the referral code consistently on both client and server.

Suggested change
import { type NextRequest, NextResponse } from 'next/server';
import { isAddress } from 'viem';
import { callDataApiInternal } from '@/utils/dataApiInternal';
import { type NextRequest, NextResponse } from 'next/server';
import { isAddress } from 'viem';
import { callDataApiInternal } from '@/utils/dataApiInternal';
import { normalizeReferralCode } from '@/utils/referrals';


const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/;

export async function POST(request: NextRequest) {
let body: unknown;

try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}

if (!isRecord(body)) {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}

const referredWallet = readString(body.referredWallet);
const referralCode = readString(body.referralCode);
const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN;
const txHash = readString(body.txHash);

if (
!referredWallet ||
!isAddress(referredWallet) ||
!referralCode ||
!Number.isInteger(chainId) ||
!txHash ||
!TX_HASH_PATTERN.test(txHash)
) {
return NextResponse.json({ error: 'Invalid referral attribution request.' }, { status: 400 });
}
Comment on lines +20 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Normalize the referred wallet and transaction hash to lowercase. Use the shared normalizeReferralCode utility to validate and normalize the referral code format and length.

Suggested change
const referredWallet = readString(body.referredWallet);
const referralCode = readString(body.referralCode);
const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN;
const txHash = readString(body.txHash);
if (
!referredWallet ||
!isAddress(referredWallet) ||
!referralCode ||
!Number.isInteger(chainId) ||
!txHash ||
!TX_HASH_PATTERN.test(txHash)
) {
return NextResponse.json({ error: 'Invalid referral attribution request.' }, { status: 400 });
}
const referredWallet = readString(body.referredWallet)?.toLowerCase();
const referralCode = normalizeReferralCode(readString(body.referralCode));
const chainId = typeof body.chainId === 'number' ? body.chainId : Number.NaN;
const txHash = readString(body.txHash)?.toLowerCase();
if (
!referredWallet ||
!isAddress(referredWallet) ||
!referralCode ||
!Number.isInteger(chainId) ||
!txHash ||
!TX_HASH_PATTERN.test(txHash)
) {
return NextResponse.json({ error: 'Invalid referral attribution request.' }, { status: 400 });
}


try {
const response = await callDataApiInternal('/internal/referrals/attribute', {
referredWallet,
referralCode,
chainId,
txHash,
});
const data = (await response.json().catch(() => ({}))) as { error?: unknown };

if (!response.ok) {
return NextResponse.json(
{ error: typeof data.error === 'string' ? data.error : 'Failed to record referral attribution.' },
{ status: response.status || 502 },
);
}

return NextResponse.json(data);
} catch {
return NextResponse.json({ error: 'Failed to record referral attribution.' }, { status: 500 });
}
}

function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Loading