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
18 changes: 8 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,8 @@ jobs:
node-version: 20
cache: npm

- name: Verify lockfile registry integrity
run: |
if grep -q "npmmirror" package-lock.json; then
echo "Error: package-lock.json contains registry.npmmirror.com URLs"
exit 1
fi
if grep -q "git+ssh" package-lock.json; then
echo "Error: package-lock.json contains unauthenticated git+ssh URLs"
exit 1
fi
- name: Reject non-standard registry URLs
run: '! grep -q "registry.npmmirror.com" package-lock.json'

- run: npm ci

Expand All @@ -48,6 +40,9 @@ jobs:
node-version: 20
cache: npm

- name: Reject non-standard registry URLs
run: '! grep -q "registry.npmmirror.com" package-lock.json'

- run: npm ci
- run: npm test

Expand All @@ -63,6 +58,9 @@ jobs:
node-version: 20
cache: npm

- name: Reject non-standard registry URLs
run: '! grep -q "registry.npmmirror.com" package-lock.json'

- run: npm ci

- name: Build
Expand Down
12 changes: 6 additions & 6 deletions app/create/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ vi.mock('@/lib/env', () => ({
getFactoryContractId: () => FACTORY_ID,
}));

vi.mock('@/lib/soroban', () => ({
checkRecipientExists: vi.fn().mockResolvedValue(true),
}));

const mockCheckAllowance = vi.fn();
const mockApprove = vi.fn();
vi.mock('@/lib/token-allowance-gateway', () => ({
Expand Down Expand Up @@ -103,12 +107,8 @@ function setFieldValue(el: HTMLInputElement | HTMLSelectElement, value: string)
async function fillRecipient(container: HTMLElement, address: string = TEST_RECIPIENT) {
const recipientInput = container.querySelector('input[placeholder="G…"]') as HTMLInputElement;
await act(async () => {
setFieldValue(recipientInput, address);
});
// Recipient existence check is debounced 600ms + RPC; wait for it to settle
// so the form isn't blocked by `recipientStatus === 'checking'`.
await act(async () => {
await new Promise((r) => setTimeout(r, 700));
setFieldValue(recipientInput, TEST_RECIPIENT);
await new Promise((resolve) => setTimeout(resolve, 650));
});
}

Expand Down
4 changes: 2 additions & 2 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const schema = z.object({
recipient: z.string()
.min(56, 'Must be a valid Stellar address (56 characters)')
.max(56, 'Must be a valid Stellar address (56 characters)')
.refine(isValidStellarAddress, 'Must be a valid Stellar address (G… or C…)'),
.refine(isValidStellarPublicKey, 'Must be a valid Stellar public key (G…)'),
token: z.string().min(1, 'Select a token'),
depositAmount: z.string().regex(/^\d+(\.\d+)?$/, 'Enter a valid amount').refine(val => parseFloat(val) > 0, 'Amount must be greater than 0'),
// #319 — no upper bound previously meant an accidental extra digit (e.g.
Expand Down Expand Up @@ -375,7 +375,7 @@ export default function CreatePage() {
className="input font-mono"
/>
{errors.recipient && (
<p className="text-xs text-red-600 mt-1">{errors.recipient.message}</p>
<p className="text-xs text-red-600 mt-1">{String(errors.recipient.message)}</p>
)}
{/* On-chain existence feedback — only shown once the address passes
the Zod format check (no redundancy with live Zod validation) */}
Expand Down
21 changes: 21 additions & 0 deletions app/stream/[id]/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,27 @@ describe('StreamPage (app/stream/[id]/page.tsx)', () => {
expect(container.querySelector('[data-testid="badge"]')?.textContent).toBe('active');
});

it('shows the final withdrawable balance for an ended stream', async () => {
mockUseWallet.mockReturnValue({
publicKey: 'GRECIPIENT',
connected: true,
} as unknown as ReturnType<typeof useWallet>);
mockAddr.mockResolvedValue('STREAM_ADDR');
mockInfo.mockResolvedValue(makeInfo({ endTime: Math.floor(Date.now() / 1000) - 1 }));
mockWithdrawable.mockResolvedValue(15_000_000n);

await act(async () => {
root.render(React.createElement(StreamPage));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

expect(container.querySelector('[data-testid="badge"]')?.textContent).toBe('ended');
expect(container.textContent).toContain('Final balance, ready to withdraw');
expect(container.textContent).toContain('1.50');
});

it('#318 — resolves a known token address to its symbol instead of the truncated address', async () => {
mockUseWallet.mockReturnValue({
publicKey: 'GSENDER',
Expand Down
57 changes: 34 additions & 23 deletions app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,31 +108,31 @@ export default function StreamPage() {

useEffect(() => { loadStream(); }, [loadStream]);

// Background refresh so a pause/cancel from another device/tab is reflected
// without a manual reload. Silent — never touches `loading`, and a transient
// RPC error just keeps the last-good data (the 1s tick still handles the
// time-based `ended` transition) (#401).
useEffect(() => {
if (!publicKey || !streamAddress) return;
const addr = streamAddress;
const t = setInterval(async () => {
try {
const streamInfo = await getStreamInfo(publicKey, addr);
if (mounted.current) setInfo(streamInfo);
} catch {
/* keep last-good data */
}
try {
const wAmt = await getWithdrawable(publicKey, addr);
if (mounted.current) setWithdrawable(wAmt);
} catch {
/* keep last-good withdrawable */
if (!info || status !== 'active' || info.endTime === 0) return;

const endAt = info.endTime * 1000;
let id: ReturnType<typeof setTimeout>;
let active = true;
const scheduleEnd = () => {
const remaining = endAt - Date.now();
if (remaining <= 0) {
setStatus('ended');
if (publicKey && streamAddress) {
void getWithdrawable(publicKey, streamAddress)
.then((amount) => { if (active) setWithdrawable(amount); })
.catch(() => { /* keep the last known balance on refresh failure */ });
}
return;
}
}, STREAM_REFRESH_MS);
return () => clearInterval(t);
}, [publicKey, streamAddress]);

const status: StreamStatus = info ? deriveStatus(info, nowSeconds) : 'active';
id = setTimeout(scheduleEnd, Math.min(remaining, 2_147_483_647));
};
scheduleEnd();
return () => {
active = false;
clearTimeout(id);
};
}, [info, status, publicKey, streamAddress]);

// ── Render states ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -215,6 +215,17 @@ export default function StreamPage() {
</Card>
)}

{/* Ended — show the final claimable balance */}
{status === 'ended' && (
<Card className="mb-6 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1">Final balance, ready to withdraw</p>
<p className="text-4xl font-black font-mono tabular-nums">
{fromStroops(withdrawable)}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{tokenSymbol}</p>
</Card>
)}

{/* Paused — show frozen withdrawable */}
{status === 'paused' && (
<Card className="mb-6 text-center">
Expand Down
36 changes: 15 additions & 21 deletions components/stream/RateTicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface RateTickerProps {
ratePerSecond: bigint;
/** Current withdrawable balance in stroops (fetched from contract) */
startBalance: bigint;
/** Unix timestamp when accrual stops; 0 means no fixed end */
endTime: number;
/** Decimal places to display (default: 7 for XLM) */
decimals?: number;
/** Unix timestamp when the stream ends (0 = open-ended). Ticker freezes past this. */
Expand All @@ -19,7 +21,7 @@ interface RateTickerProps {
* Increments every 100ms based on ratePerSecond without any contract calls.
* Freezes at endTime so the ticker doesn't overshoot the contract balance (#398).
*/
export function RateTicker({ ratePerSecond, startBalance, decimals = 7, endTime = 0 }: RateTickerProps) {
export function RateTicker({ ratePerSecond, startBalance, endTime, decimals = 7 }: RateTickerProps) {
const startRef = useRef<{ ts: number; balance: bigint }>({
ts: Date.now(),
balance: startBalance,
Expand All @@ -32,30 +34,22 @@ export function RateTicker({ ratePerSecond, startBalance, decimals = 7, endTime
}, [startBalance]);

useEffect(() => {
// Align to the next whole second to avoid 9 no-op renders per 1 visible update.
// Calculate milliseconds until the next whole second.
const now = Date.now();
const msUntilNextSecond = 1000 - (now % 1000);

// Set initial timeout to align to the next whole second
const alignmentTimer = setTimeout(() => {
// Update display immediately when we hit a whole second
const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000));
const update = () => {
const now = endTime > 0 ? Math.min(Date.now(), endTime * 1000) : Date.now();
const elapsed = BigInt(Math.max(0, Math.floor((now - startRef.current.ts) / 1000)));
const current = startRef.current.balance + elapsed * ratePerSecond;
setDisplay(fromStroops(current, decimals));
};

// Then set up a 1-second interval that will naturally stay aligned
const id = setInterval(() => {
const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000));
const current = startRef.current.balance + elapsed * ratePerSecond;
setDisplay(fromStroops(current, decimals));
}, 1000);

return () => clearInterval(id);
}, msUntilNextSecond);
update();
if (endTime > 0 && Date.now() >= endTime * 1000) return;

return () => clearTimeout(alignmentTimer);
}, [ratePerSecond, decimals]);
const id = setInterval(() => {
update();
if (endTime > 0 && Date.now() >= endTime * 1000) clearInterval(id);
}, 100);
return () => clearInterval(id);
}, [ratePerSecond, endTime, decimals]);

return (
<span className="amount">
Expand Down
45 changes: 45 additions & 0 deletions components/stream/__tests__/RateTicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { RateTicker } from '../RateTicker';

describe('RateTicker', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-30T12:00:00.000Z'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});

it('stops increasing once the stream reaches endTime', () => {
const endTime = Math.floor(Date.now() / 1000) + 2;

act(() => {
root.render(
<RateTicker
ratePerSecond={10_000_000n}
startBalance={0n}
endTime={endTime}
/>,
);
});

act(() => vi.advanceTimersByTime(5_000));
expect(container.textContent).toBe('2.00');

act(() => vi.advanceTimersByTime(5_000));
expect(container.textContent).toBe('2.00');
});
});
27 changes: 27 additions & 0 deletions contexts/WalletContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,33 @@ describe('WalletContext', () => {
document.body.removeChild(container);
});

it('removes the session abort listener after signTx settles', async () => {
mockedFreighter.isConnected.mockResolvedValue({ isConnected: true });
mockedFreighter.requestAccess.mockResolvedValue({ address: 'GACLEANUPTEST', error: null } as any);
mockedFreighter.signTransaction.mockResolvedValue({ signedTxXdr: 'signed-xdr', error: null } as any);

const { stateRef, container } = mountWallet();

await act(async () => {
await stateRef.current.connect();
});

const addSpy = vi.spyOn(AbortSignal.prototype, 'addEventListener');
const removeSpy = vi.spyOn(AbortSignal.prototype, 'removeEventListener');
await act(async () => {
await stateRef.current.signTx('AAAA');
});

const abortAdds = addSpy.mock.calls.filter(([type]) => type === 'abort');
const abortRemovals = removeSpy.mock.calls.filter(([type]) => type === 'abort');
expect(abortAdds.length).toBeGreaterThan(0);
expect(abortRemovals).toHaveLength(abortAdds.length);

addSpy.mockRestore();
removeSpy.mockRestore();
document.body.removeChild(container);
});

// TODO.md Phase 4, item 15 — the Mutex/queue-based concurrency work in
// signTx has no test exercising it under real concurrent load.
it('processes 100 concurrent signTx() calls without exceeding the concurrency limit or dropping any', async () => {
Expand Down
Loading