Skip to content

Commit a768741

Browse files
authored
Merge pull request #925 from LawalRahman/Withdraw-mutation-invalidates-immediately-against-lagging-indexer
2 parents e37a610 + 93c82d9 commit a768741

9 files changed

Lines changed: 334 additions & 100 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ coverage
88

99
# Soroban test runner — auto-generated, never commit
1010
**/test_snapshots/
11-
fix.md
11+
fix.md
12+
.npm-cache/
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
import { useStreamingAmount } from "../hooks/useStreamingAmount";
4+
5+
describe("useStreamingAmount", () => {
6+
let mockTimeMs = 1000 * 1000 * 1000; // t = 1,000,000s in ms
7+
8+
beforeEach(() => {
9+
vi.useFakeTimers();
10+
mockTimeMs = 1000 * 1000 * 1000;
11+
12+
// Mock performance.now and Date.now to return our simulated clock
13+
vi.spyOn(performance, "now").mockImplementation(() => mockTimeMs);
14+
vi.spyOn(Date, "now").mockImplementation(() => mockTimeMs);
15+
16+
// Mock requestAnimationFrame and cancelAnimationFrame to run under fake timers
17+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
18+
return setTimeout(() => {
19+
mockTimeMs += 16;
20+
cb(mockTimeMs);
21+
}, 16) as unknown as number;
22+
});
23+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => {
24+
clearTimeout(id as unknown as NodeJS.Timeout);
25+
});
26+
});
27+
28+
afterEach(() => {
29+
vi.useRealTimers();
30+
vi.restoreAllMocks();
31+
});
32+
33+
it("accurately streams amount, caps at deposited - withdrawn, and drops to 0 after withdraw update", () => {
34+
const params = {
35+
deposited: 1000,
36+
withdrawn: 0,
37+
ratePerSecond: 1,
38+
startTime: 1000 * 1000 - 100, // started 100 seconds ago (at t = 999,900s)
39+
isActive: true,
40+
};
41+
42+
const { result, rerender } = renderHook(
43+
(props) => useStreamingAmount(props),
44+
{ initialProps: params }
45+
);
46+
47+
// Initial claimable (elapsed = 1,000,000 - 999,900 = 100s, rate = 1/sec)
48+
expect(result.current).toBe(100);
49+
50+
// Advance time by 10 seconds (now at t = 1,000,010s)
51+
act(() => {
52+
vi.advanceTimersByTime(10000);
53+
});
54+
// Claimable should accrue 10 more to 110 (with minor tolerance for 16ms ticks)
55+
expect(result.current).toBeCloseTo(110, 1);
56+
57+
// Simulate withdraw (optimistic update):
58+
// withdrawn is bumped to 110 (pre-withdrawn + claimable)
59+
// lastUpdateTime is bumped to current time (1,000,010s)
60+
// startTime is removed/omitted to anchor on lastUpdateTime
61+
const updatedParams = {
62+
deposited: 1000,
63+
withdrawn: 110,
64+
ratePerSecond: 1,
65+
lastUpdateTime: 1000 * 1000 + 10,
66+
isActive: true,
67+
};
68+
69+
rerender(updatedParams);
70+
71+
// Claimable must drop to 0 immediately (does not regress/jump back up)
72+
expect(result.current).toBe(0);
73+
74+
// Advance time by 5 seconds (now at t = 1,000,015s)
75+
act(() => {
76+
vi.advanceTimersByTime(5000);
77+
});
78+
// Claimable should accrue from 0 since lastUpdateTime (elapsed = 1,000,015 - 1,000,010 = 5s, rate = 1/sec)
79+
expect(result.current).toBeCloseTo(5, 1);
80+
81+
// Advance time past the remaining cap (cap = 1000 - 110 = 890)
82+
act(() => {
83+
vi.advanceTimersByTime(1000 * 1000);
84+
});
85+
// Claimable must be capped at 890
86+
expect(result.current).toBe(890);
87+
});
88+
});

frontend/src/app/streams/create/page.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
TOKEN_ADDRESSES
1111
} from "@/lib/soroban";
1212
import { hasValidPrecision, validateAmountInput } from "@/utils/amount";
13+
import { isValidStellarPublicKey } from "@/lib/stellar";
1314
import { toast } from "react-hot-toast";
1415
import { useRouter } from "next/navigation";
1516
import Link from "next/link";
@@ -38,13 +39,30 @@ export default function CreateStreamPage() {
3839
return;
3940
}
4041

42+
// Validate recipient
43+
if (!formData.recipient.trim()) {
44+
toast.error("Recipient address is required");
45+
return;
46+
}
47+
if (!isValidStellarPublicKey(formData.recipient)) {
48+
toast.error("Invalid Stellar public key format");
49+
return;
50+
}
51+
4152
// Validate amount
4253
const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS);
4354
if (validationError) {
4455
toast.error(validationError);
4556
return;
4657
}
4758

59+
// Validate duration
60+
const durationNum = parseFloat(formData.duration);
61+
if (isNaN(durationNum) || durationNum <= 0) {
62+
toast.error("Duration must be a positive number");
63+
return;
64+
}
65+
4866
setLoading(true);
4967
setTxState("signing");
5068

@@ -94,6 +112,16 @@ export default function CreateStreamPage() {
94112
? validateAmountInput(formData.amount, TOKEN_DECIMALS)
95113
: null;
96114

115+
const recipientError = formData.recipient
116+
? (!isValidStellarPublicKey(formData.recipient) ? "Invalid Stellar public key format" : null)
117+
: null;
118+
119+
const durationError = formData.duration
120+
? (isNaN(Number(formData.duration)) || Number(formData.duration) <= 0
121+
? "Duration must be a positive number"
122+
: null)
123+
: null;
124+
97125
return (
98126
<div className="container mx-auto max-w-2xl px-4 py-12">
99127
<Link
@@ -118,11 +146,16 @@ export default function CreateStreamPage() {
118146
<input
119147
type="text"
120148
placeholder="G..."
121-
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
149+
className={`w-full rounded-xl border ${
150+
recipientError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
151+
} bg-slate-900/50 p-4 outline-none transition-colors`}
122152
value={formData.recipient}
123153
onChange={(e) => setFormData({ ...formData, recipient: e.target.value })}
124154
required
125155
/>
156+
{recipientError && (
157+
<p className="text-xs text-red-400 mt-1">{recipientError}</p>
158+
)}
126159
</div>
127160

128161
<div className="grid grid-cols-2 gap-4">
@@ -150,7 +183,9 @@ export default function CreateStreamPage() {
150183
type="text"
151184
inputMode="decimal"
152185
placeholder="0.00"
153-
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
186+
className={`w-full rounded-xl border ${
187+
amountError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
188+
} bg-slate-900/50 p-4 outline-none transition-colors`}
154189
value={formData.amount}
155190
onChange={(e) => {
156191
const newValue = e.target.value;
@@ -176,33 +211,40 @@ export default function CreateStreamPage() {
176211
<input
177212
type="number"
178213
placeholder="30"
179-
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
214+
className={`w-full rounded-xl border ${
215+
durationError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
216+
} bg-slate-900/50 p-4 outline-none transition-colors`}
180217
value={formData.duration}
181218
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
182219
required
183220
/>
221+
{durationError && (
222+
<p className="text-xs text-red-400 mt-1">{durationError}</p>
223+
)}
184224
</div>
185225

186226
<div className="rounded-2xl bg-accent/5 p-6 space-y-4">
187227
<div className="flex justify-between items-center text-sm">
188228
<span className="text-slate-400">Streaming Rate</span>
189229
<span className="font-mono font-medium text-accent">
190-
{formData.amount && formData.duration
230+
{formData.amount && formData.duration && Number(formData.duration) > 0
191231
? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8)
192232
: "0.00000000"} {formData.token}/sec
193233
</span>
194234
</div>
195235
<div className="flex justify-between items-center text-sm">
196236
<span className="text-slate-400">Estimated End Date</span>
197237
<span className="font-medium">
198-
{new Date(nowTimestamp + Number(formData.duration || 0) * 86400000).toLocaleDateString()}
238+
{formData.duration && Number(formData.duration) > 0
239+
? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString()
240+
: "—"}
199241
</span>
200242
</div>
201243
</div>
202244

203245
<button
204246
type="submit"
205-
disabled={loading || status !== "connected"}
247+
disabled={loading || status !== "connected" || !!amountError || !!recipientError || !!durationError}
206248
className="w-full rounded-xl bg-accent py-4 text-lg font-bold text-background transition-all hover:opacity-90 disabled:opacity-50 active:scale-[0.98]"
207249
>
208250
{getButtonText()}

frontend/src/components/stream-creation/ScheduleStep.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ export const ScheduleStep: React.FC<ScheduleStepProps> = ({
8181
const ratePerDayPreview = useMemo(() => {
8282
if (!ratePerSecond) return null;
8383
const dailyRate = ratePerSecond * 86400;
84-
if (token === "USDC" || token === "EURC") {
84+
if (token === "USDC") {
8585
return `$${dailyRate.toFixed(2)} / day`;
8686
}
87+
if (token === "EURC") {
88+
return `€${dailyRate.toFixed(2)} / day`;
89+
}
8790
return `${dailyRate.toFixed(4)} ${token || ""} / day`;
8891
}, [ratePerSecond, token]);
8992

frontend/src/components/streams/IncomingStreamCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function IncomingStreamCard({
4141
deposited: stream.deposited,
4242
withdrawn: stream.withdrawn,
4343
ratePerSecond: stream.ratePerSecond,
44-
startTime: stream.startTime,
44+
lastUpdateTime: stream.lastUpdateTime,
4545
isActive: stream.isActive,
4646
isPaused: stream.isPaused,
4747
pausedAt: stream.pausedAt,

frontend/src/components/ui/Stepper.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ export const Stepper: React.FC<StepperProps> = ({
1313
className = "",
1414
}) => {
1515
return (
16-
<div className={`stepper ${className}`}>
17-
<div className="stepper__container">
16+
<nav className={`stepper ${className}`} aria-label="Progress">
17+
<ol className="stepper__container">
1818
{steps.map((step, index) => {
1919
const stepNumber = index + 1;
2020
const isActive = stepNumber === currentStep;
2121
const isCompleted = stepNumber < currentStep;
2222

2323
return (
2424
<React.Fragment key={step}>
25-
<div className="stepper__step">
25+
<li className="stepper__step" aria-current={isActive ? "step" : undefined}>
2626
<div
2727
className={`stepper__circle ${
2828
isActive
@@ -60,19 +60,23 @@ export const Stepper: React.FC<StepperProps> = ({
6060
}`}
6161
>
6262
{step}
63+
<span className="sr-only">
64+
{isActive ? " (current step)" : isCompleted ? " (completed)" : " (upcoming)"}
65+
</span>
6366
</span>
64-
</div>
67+
</li>
6568
{index < steps.length - 1 && (
6669
<div
6770
className={`stepper__line ${
6871
isCompleted ? "stepper__line--completed" : ""
6972
}`}
73+
aria-hidden="true"
7074
/>
7175
)}
7276
</React.Fragment>
7377
);
7478
})}
75-
</div>
76-
</div>
79+
</ol>
80+
</nav>
7781
);
7882
};

0 commit comments

Comments
 (0)