diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dcd4d0..0116416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/app/create/__tests__/page.test.tsx b/app/create/__tests__/page.test.tsx index 1d48130..0ce0549 100644 --- a/app/create/__tests__/page.test.tsx +++ b/app/create/__tests__/page.test.tsx @@ -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', () => ({ @@ -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)); }); } diff --git a/app/create/page.tsx b/app/create/page.tsx index 6d0e33c..1246041 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -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. @@ -375,7 +375,7 @@ export default function CreatePage() { className="input font-mono" /> {errors.recipient && ( -

{errors.recipient.message}

+

{String(errors.recipient.message)}

)} {/* On-chain existence feedback — only shown once the address passes the Zod format check (no redundancy with live Zod validation) */} diff --git a/app/stream/[id]/__tests__/page.test.tsx b/app/stream/[id]/__tests__/page.test.tsx index 51cf7ac..d045d7a 100644 --- a/app/stream/[id]/__tests__/page.test.tsx +++ b/app/stream/[id]/__tests__/page.test.tsx @@ -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); + 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', diff --git a/app/stream/[id]/page.tsx b/app/stream/[id]/page.tsx index cff11c9..c027b3a 100644 --- a/app/stream/[id]/page.tsx +++ b/app/stream/[id]/page.tsx @@ -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; + 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 ───────────────────────────────────────────────────────── @@ -215,6 +215,17 @@ export default function StreamPage() { )} + {/* Ended — show the final claimable balance */} + {status === 'ended' && ( + +

Final balance, ready to withdraw

+

+ {fromStroops(withdrawable)} +

+

{tokenSymbol}

+
+ )} + {/* Paused — show frozen withdrawable */} {status === 'paused' && ( diff --git a/components/stream/RateTicker.tsx b/components/stream/RateTicker.tsx index 4e2b94f..6e6a498 100644 --- a/components/stream/RateTicker.tsx +++ b/components/stream/RateTicker.tsx @@ -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. */ @@ -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, @@ -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 ( diff --git a/components/stream/__tests__/RateTicker.test.tsx b/components/stream/__tests__/RateTicker.test.tsx new file mode 100644 index 0000000..ffa89b0 --- /dev/null +++ b/components/stream/__tests__/RateTicker.test.tsx @@ -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( + , + ); + }); + + act(() => vi.advanceTimersByTime(5_000)); + expect(container.textContent).toBe('2.00'); + + act(() => vi.advanceTimersByTime(5_000)); + expect(container.textContent).toBe('2.00'); + }); +}); diff --git a/contexts/WalletContext.test.tsx b/contexts/WalletContext.test.tsx index 5e8ae51..5a00a95 100644 --- a/contexts/WalletContext.test.tsx +++ b/contexts/WalletContext.test.tsx @@ -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 () => { diff --git a/contexts/WalletContext.tsx b/contexts/WalletContext.tsx index 6619b35..41532dc 100644 --- a/contexts/WalletContext.tsx +++ b/contexts/WalletContext.tsx @@ -529,48 +529,49 @@ export function WalletProvider({ const globalAbortCleanup = () => { operationAbortController.abort(); }; - abortControllerRef.current?.signal.addEventListener('abort', globalAbortCleanup, { once: true }); + const globalAbortSignal = abortControllerRef.current?.signal; + globalAbortSignal?.addEventListener('abort', globalAbortCleanup, { once: true }); - // Acquire a semaphore permit — limits concurrent Freighter popups - const release = await semaphoreRef.current.acquire(combinedSignal); - - return trackOperation(async () => { - try { - if (combinedSignal.aborted) { - throw new Error('Operation aborted'); - } + try { + // Acquire a semaphore permit — limits concurrent Freighter popups + const release = await semaphoreRef.current.acquire(combinedSignal); - const requestId = pendingRequestIdRef.current; - const currentPublicKey = publicKeyRef.current; - const { signedTxXdr, error } = await withTimeout( - signTransaction(xdr, { - networkPassphrase: getNetworkPassphrase(), - address: currentPublicKey ?? undefined, - }), - WALLET_CONNECT_TIMEOUT_MS, - { label: 'Freighter signing', onTimeout: walletTimeoutError }, - ); + return await trackOperation(async () => { + try { + if (combinedSignal.aborted) { + throw new Error('Operation aborted'); + } - if (combinedSignal.aborted) { - throw new Error('Operation aborted'); - } + const requestId = pendingRequestIdRef.current; + const currentPublicKey = publicKeyRef.current; + const { signedTxXdr, error } = await withTimeout( + signTransaction(xdr, { + networkPassphrase: getNetworkPassphrase(), + address: currentPublicKey ?? undefined, + }), + WALLET_CONNECT_TIMEOUT_MS, + 'Freighter signing', + ); + + if (combinedSignal.aborted) { + throw new Error('Operation aborted'); + } - if (requestId !== pendingRequestIdRef.current || currentPublicKey !== publicKeyRef.current) { - throw new Error('Wallet state changed during signing. Please retry the operation.'); - } + if (requestId !== pendingRequestIdRef.current || currentPublicKey !== publicKeyRef.current) { + throw new Error('Wallet state changed during signing. Please retry the operation.'); + } - if (error || !signedTxXdr) { - throw new Error(error?.message ?? 'Failed to sign transaction in Freighter.'); + if (error || !signedTxXdr) { + throw new Error(error?.message ?? 'Failed to sign transaction in Freighter.'); + } + return signedTxXdr; + } finally { + release(); } - // A successful signature is meaningful activity — keep the session - // alive rather than let it lapse mid-use (#430). - touchWalletSession(); - return signedTxXdr; - } finally { - release(); - abortControllerRef.current?.signal.removeEventListener('abort', globalAbortCleanup); - } - }); + }); + } finally { + globalAbortSignal?.removeEventListener('abort', globalAbortCleanup); + } }, [publicKey, trackOperation]); // ── Memoized context value ───────────────────────────────────────────────── diff --git a/lib/soroban-pipeline.test.ts b/lib/soroban-pipeline.test.ts index a9e1c3b..bd5a179 100644 --- a/lib/soroban-pipeline.test.ts +++ b/lib/soroban-pipeline.test.ts @@ -190,6 +190,7 @@ describe('invokeContract', () => { }); it('falls back to a multiple of BASE_FEE when fee stats are unavailable', async () => { + vi.setSystemTime(Date.now() + 31_000); mockGetFeeStats.mockRejectedValue(new Error('method not supported')); mockSimulate.mockResolvedValue(simSuccess()); mockGetTransaction.mockResolvedValue({ status: 'SUCCESS' }); @@ -216,7 +217,7 @@ describe('invokeContract', () => { promise.catch(() => {}); await vi.advanceTimersByTimeAsync(2000); - expect(await promise).toEqual(expect.objectContaining({ hash: 'deadbeef' })); + expect((await promise).hash).toBe('deadbeef'); expect(signTx).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalledTimes(1); }); @@ -231,7 +232,7 @@ describe('invokeContract', () => { promise.catch(() => {}); await vi.advanceTimersByTimeAsync(31_000); - expect(await promise).toEqual(expect.objectContaining({ hash: 'deadbeef' })); + expect((await promise).hash).toBe('deadbeef'); expect(signTx).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalledTimes(1); }); diff --git a/lib/stream.test.ts b/lib/stream.test.ts index 026d94a..94e68cc 100644 --- a/lib/stream.test.ts +++ b/lib/stream.test.ts @@ -116,9 +116,8 @@ describe('getStreamInfo', () => { it('throws a clear error when a field is missing rather than silently defaulting', async () => { mockSimulateReadOnly.mockResolvedValue(scvMap({ sender: new Address(SENDER).toScVal(), - // recipient deliberately omitted — flags included so the missing-recipient - // path is exercised first (flags is now read before sender/recipient). flags: xdr.ScVal.scvU32(0), + // recipient deliberately omitted })); const { getStreamInfo } = await import('./stream.js'); await expect(getStreamInfo(SENDER, STREAM_ADDRESS)).rejects.toThrow(/Missing field: recipient/); diff --git a/lib/tokens.test.ts b/lib/tokens.test.ts index 541c964..bb246c9 100644 --- a/lib/tokens.test.ts +++ b/lib/tokens.test.ts @@ -127,7 +127,7 @@ describe('SEP-41 Token Allowance Helpers (#347, #348)', () => { }); it('approveAllowance calls SAC with 4 arguments and succeeds (#347)', async () => { - vi.mocked(soroban.invokeContract).mockResolvedValueOnce({ hash: 'tx_hash_123' } as any); + vi.mocked(soroban.invokeContract).mockResolvedValueOnce({ hash: 'tx_hash_123' }); const mockSignTx = vi.fn().mockResolvedValue('signed'); const result = await approveAllowance( @@ -151,7 +151,7 @@ describe('SEP-41 Token Allowance Helpers (#347, #348)', () => { ); // Verify 4 arguments were passed to invokeContract - const passedArgs = vi.mocked(soroban.invokeContract).mock.calls[0]![3] as unknown[]; + const passedArgs = vi.mocked(soroban.invokeContract).mock.calls[0]![3]; expect(passedArgs).toHaveLength(4); }); diff --git a/package-lock.json b/package-lock.json index c5987a9..26f4545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@conduit-protocol/app", "version": "0.1.0", "dependencies": { - "@conduit-protocol/sdk": "git+https://github.com/conduit-protocol/streamFi-sdk.git", + "@conduit-protocol/sdk": "https://codeload.github.com/conduit-protocol/streamFi-sdk/tar.gz/5de4fce26d3d3c73cf56f81e0bd54965dcf831a1", "@hookform/resolvers": "^5.4.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^12.0.0", @@ -164,7 +164,8 @@ }, "node_modules/@conduit-protocol/sdk": { "version": "0.2.0", - "resolved": "git+https://github.com/conduit-protocol/streamFi-sdk.git#839081cbcd5229bd8b1af074f7694b8ace90bd71", + "resolved": "https://codeload.github.com/conduit-protocol/streamFi-sdk/tar.gz/5de4fce26d3d3c73cf56f81e0bd54965dcf831a1", + "integrity": "sha512-4Zwzy0RPjzisGoqJHYx28jNmbNwZe1juteFwF9SJxlhgj3593n0hU+IH5tZWmLosIvPDA6oUcJJ4rnqb48HCCw==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -3122,6 +3123,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5076,6 +5094,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "extraneous": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -6251,6 +6286,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "extraneous": true, + "license": "MIT" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", diff --git a/package.json b/package.json index 2378129..9a83bfc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "dependencies": { - "@conduit-protocol/sdk": "git+https://github.com/conduit-protocol/streamFi-sdk.git", + "@conduit-protocol/sdk": "https://codeload.github.com/conduit-protocol/streamFi-sdk/tar.gz/5de4fce26d3d3c73cf56f81e0bd54965dcf831a1", "@hookform/resolvers": "^5.4.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^12.0.0",