Skip to content

Commit 4380b83

Browse files
authored
Merge branch 'main' into feature/517-api-rate-limit-feedback
2 parents 2045f85 + f2da109 commit 4380b83

104 files changed

Lines changed: 7685 additions & 675 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { resolveRounding } from '@/hooks/useSplitCalculator';
2+
3+
describe('resolveRounding', () => {
4+
const STROOP_SCALE = 1e7;
5+
6+
it('should resolve rounding for 3-recipient split', () => {
7+
const percentages = [33.33, 33.33, 33.34];
8+
const totalAmount = 100;
9+
10+
const result = resolveRounding(percentages, totalAmount);
11+
12+
expect(result.amounts).toHaveLength(3);
13+
const sumStroops = result.amounts.reduce(
14+
(s, a) => s + Math.round(a * STROOP_SCALE),
15+
0
16+
);
17+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
18+
expect(sumStroops).toBe(totalStroops);
19+
});
20+
21+
it('should resolve rounding for 7-recipient split', () => {
22+
const percentages = [14.28, 14.28, 14.28, 14.28, 14.28, 14.29, 14.31];
23+
const totalAmount = 1000;
24+
25+
const result = resolveRounding(percentages, totalAmount);
26+
27+
expect(result.amounts).toHaveLength(7);
28+
const sumStroops = result.amounts.reduce(
29+
(s, a) => s + Math.round(a * STROOP_SCALE),
30+
0
31+
);
32+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
33+
expect(sumStroops).toBe(totalStroops);
34+
});
35+
36+
it('should resolve rounding for 11-recipient split', () => {
37+
const percentages = Array(11)
38+
.fill(0)
39+
.map((_, i) => (i === 10 ? 9.1 : 9.09));
40+
const totalAmount = 5000;
41+
42+
const result = resolveRounding(percentages, totalAmount);
43+
44+
expect(result.amounts).toHaveLength(11);
45+
const sumStroops = result.amounts.reduce(
46+
(s, a) => s + Math.round(a * STROOP_SCALE),
47+
0
48+
);
49+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
50+
expect(sumStroops).toBe(totalStroops);
51+
});
52+
53+
it('should assign adjustment to first recipient', () => {
54+
const percentages = [50, 50];
55+
const totalAmount = 100;
56+
57+
const result = resolveRounding(percentages, totalAmount);
58+
59+
expect(result.recipientIndex).toBe(0);
60+
});
61+
62+
it('should return zero adjustment when no rounding needed', () => {
63+
const percentages = [25, 25, 25, 25];
64+
const totalAmount = 100;
65+
66+
const result = resolveRounding(percentages, totalAmount);
67+
68+
const sumStroops = result.amounts.reduce(
69+
(s, a) => s + Math.round(a * STROOP_SCALE),
70+
0
71+
);
72+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
73+
expect(sumStroops).toBe(totalStroops);
74+
});
75+
76+
it('should handle fractional stroop amounts', () => {
77+
const percentages = [33.333, 33.333, 33.334];
78+
const totalAmount = 123.456789;
79+
80+
const result = resolveRounding(percentages, totalAmount);
81+
82+
const sumStroops = result.amounts.reduce(
83+
(s, a) => s + Math.round(a * STROOP_SCALE),
84+
0
85+
);
86+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
87+
expect(sumStroops).toBe(totalStroops);
88+
});
89+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { NextRequest } from "next/server";
2+
3+
interface CursorEvent {
4+
type: "cursor";
5+
address: string;
6+
field: string;
7+
timestamp: number;
8+
}
9+
10+
interface PresenceEvent {
11+
type: "presence";
12+
address: string;
13+
online: boolean;
14+
timestamp: number;
15+
}
16+
17+
type CollabEvent = CursorEvent | PresenceEvent;
18+
19+
interface SseClient {
20+
id: string;
21+
controller: ReadableStreamDefaultController;
22+
encoder: TextEncoder;
23+
address: string | null;
24+
}
25+
26+
const clients = new Map<string, SseClient[]>();
27+
28+
function broadcast(invoiceId: string, event: CollabEvent) {
29+
const invoiceClients = clients.get(invoiceId);
30+
if (!invoiceClients) return;
31+
const data = `data: ${JSON.stringify(event)}\n\n`;
32+
const encoded = new TextEncoder().encode(data);
33+
for (const client of invoiceClients) {
34+
try {
35+
client.controller.enqueue(encoded);
36+
} catch {
37+
// client disconnected
38+
}
39+
}
40+
}
41+
42+
function prune(invoiceId: string) {
43+
const invoiceClients = clients.get(invoiceId);
44+
if (!invoiceClients) return;
45+
const now = Date.now();
46+
const active = invoiceClients.filter(
47+
(c) => now - c.id.split("-").map(Number)[1] < 60_000
48+
);
49+
if (active.length === 0) {
50+
clients.delete(invoiceId);
51+
} else {
52+
clients.set(invoiceId, active);
53+
}
54+
}
55+
56+
export async function GET(
57+
_request: NextRequest,
58+
{ params }: { params: { invoiceId: string } }
59+
) {
60+
const { invoiceId } = params;
61+
62+
const stream = new ReadableStream({
63+
start(controller) {
64+
const client: SseClient = {
65+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
66+
controller,
67+
encoder: new TextEncoder(),
68+
address: null,
69+
};
70+
71+
if (!clients.has(invoiceId)) {
72+
clients.set(invoiceId, []);
73+
}
74+
clients.get(invoiceId)!.push(client);
75+
76+
controller.enqueue(
77+
new TextEncoder().encode(`data: ${JSON.stringify({ type: "connected", clientId: client.id })}\n\n`)
78+
);
79+
80+
prune(invoiceId);
81+
},
82+
cancel() {
83+
const invoiceClients = clients.get(invoiceId);
84+
if (invoiceClients) {
85+
const remaining = invoiceClients.filter(
86+
(c) => !c.controller.locked
87+
);
88+
if (remaining.length === 0) {
89+
clients.delete(invoiceId);
90+
broadcast(invoiceId, {
91+
type: "presence",
92+
address: "all",
93+
online: false,
94+
timestamp: Date.now(),
95+
});
96+
} else {
97+
clients.set(invoiceId, remaining);
98+
}
99+
}
100+
},
101+
});
102+
103+
return new Response(stream, {
104+
headers: {
105+
"Content-Type": "text/event-stream",
106+
"Cache-Control": "no-cache, no-transform",
107+
Connection: "keep-alive",
108+
},
109+
});
110+
}
111+
112+
export async function POST(
113+
request: NextRequest,
114+
{ params }: { params: { invoiceId: string } }
115+
) {
116+
const { invoiceId } = params;
117+
const body = (await request.json()) as { address?: string; field?: string; online?: boolean };
118+
119+
if (!body.address) {
120+
return Response.json({ error: "address is required" }, { status: 400 });
121+
}
122+
123+
if (body.field !== undefined) {
124+
broadcast(invoiceId, {
125+
type: "cursor",
126+
address: body.address,
127+
field: body.field,
128+
timestamp: Date.now(),
129+
});
130+
}
131+
132+
if (body.online !== undefined) {
133+
broadcast(invoiceId, {
134+
type: "presence",
135+
address: body.address,
136+
online: body.online,
137+
timestamp: Date.now(),
138+
});
139+
}
140+
141+
return Response.json({ ok: true });
142+
}

src/app/api/fees/route.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
export async function GET(request: NextRequest) {
4+
try {
5+
const horizonUrl = process.env.NEXT_PUBLIC_HORIZON_URL || 'https://horizon.stellar.org';
6+
7+
const response = await fetch(`${horizonUrl}/fee_stats`, {
8+
headers: {
9+
'Accept': 'application/json',
10+
},
11+
});
12+
13+
if (!response.ok) {
14+
return NextResponse.json(
15+
{ error: 'Failed to fetch fee stats from Horizon' },
16+
{ status: response.status }
17+
);
18+
}
19+
20+
const data = await response.json();
21+
22+
return NextResponse.json({
23+
baseFee: data.base_fee?.toString() || '100',
24+
resourceFee: data.soroban_resource_fee?.toString() || '0',
25+
ledgerCapacityUsage: data.ledger_capacity_usage?.toString() || '0',
26+
});
27+
} catch (error) {
28+
console.error('Error fetching fee stats:', error);
29+
return NextResponse.json(
30+
{ error: 'Failed to fetch fee data' },
31+
{ status: 500 }
32+
);
33+
}
34+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { splitClient, formatAmount } from "@stellar-split/sdk";
3+
4+
const appUrl =
5+
process.env.NEXT_PUBLIC_APP_URL ??
6+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");
7+
8+
export async function GET(
9+
_request: NextRequest,
10+
{ params }: { params: { id: string } }
11+
) {
12+
try {
13+
const invoice = await splitClient.getInvoice(params.id);
14+
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
15+
const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total);
16+
17+
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
18+
<defs>
19+
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
20+
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
21+
<stop offset="100%" style="stop-color:#2d3748;stop-opacity:1" />
22+
</linearGradient>
23+
</defs>
24+
25+
<rect width="1200" height="630" fill="url(#grad)"/>
26+
27+
<text x="60" y="120" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white">
28+
Invoice #${params.id}
29+
</text>
30+
31+
<text x="60" y="180" font-family="Arial, sans-serif" font-size="32" fill="#a0aec0">
32+
${formatAmount(total)} USDC
33+
</text>
34+
35+
<rect x="60" y="220" width="1080" height="40" rx="20" fill="#1a202c"/>
36+
<rect x="60" y="220" width="${1080 * (pct / 100)}" height="40" rx="20" fill="#10b981"/>
37+
38+
<text x="60" y="300" font-family="Arial, sans-serif" font-size="24" fill="#a0aec0">
39+
Status: ${invoice.status}
40+
</text>
41+
42+
<text x="60" y="340" font-family="Arial, sans-serif" font-size="20" fill="#a0aec0">
43+
${pct.toFixed(0)}% Funded • ${formatAmount(invoice.funded)} Received
44+
</text>
45+
46+
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#718096">
47+
View on StellarSplit
48+
</text>
49+
</svg>`;
50+
51+
return new NextResponse(svg, {
52+
headers: {
53+
"Content-Type": "image/svg+xml",
54+
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
55+
},
56+
});
57+
} catch (error) {
58+
console.error("OG image generation error:", error);
59+
60+
const fallbackSvg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
61+
<rect width="1200" height="630" fill="#1a202c"/>
62+
<text x="600" y="315" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">
63+
StellarSplit Invoice
64+
</text>
65+
</svg>`;
66+
67+
return new NextResponse(fallbackSvg, {
68+
headers: {
69+
"Content-Type": "image/svg+xml",
70+
"Cache-Control": "public, max-age=300",
71+
},
72+
});
73+
}
74+
}

0 commit comments

Comments
 (0)