Skip to content

feat(sdk): add utility class for subscribing to token balance changes#265

Open
mandyslovestories-sudo wants to merge 2 commits into
Fundable-Protocol:mainfrom
mandyslovestories-sudo:feat/balance-watcher-sdk-181
Open

feat(sdk): add utility class for subscribing to token balance changes#265
mandyslovestories-sudo wants to merge 2 commits into
Fundable-Protocol:mainfrom
mandyslovestories-sudo:feat/balance-watcher-sdk-181

Conversation

@mandyslovestories-sudo

@mandyslovestories-sudo mandyslovestories-sudo commented Jun 27, 2026

Copy link
Copy Markdown

Summary

Closes #181

Enhances BalanceWatcher with the full feature set described in the issue: stream-level subscriptions, bulk balance fetching, a last-known-balance cache, a typed error callback, and an explicit unsubscribe method. The test suite is expanded from 11 to 54 tests covering every new and existing behaviour.


Changes

src/utils/BalanceWatcher.ts

New methods:

Method Description
watchStream(stream, callback) Subscribes to both sender and recipient of a Stream in one call. Returns a single unsub function that tears down both subscriptions atomically.
fetchBalances(pairs) Bulk-fetches multiple address/token pairs in parallel. Failures are captured per-pair ({ balance: null, error }) so a single RPC error never aborts the batch.
getLastKnownBalance(address, token) Returns the cached balance from the last successful poll — no RPC call. Returns null before the first poll or when the pair is not watched.
unwatch(address, token, callback) Explicit unsubscribe (in addition to the returned closure from watch()).

New constructor option:

Option Description
onError(error, address, token) Typed callback invoked on poll failures instead of silent console.error. Gives callers full control over error handling (logging, alerting, retrying).

Improvements to existing behaviour:

  • Polling errors for one pair no longer block other pairs from being polled
  • start() is idempotent — calling it when already running does not create duplicate intervals
  • stop() is idempotent — calling it when already stopped does not throw
  • void operator on internal pollBalances() call to avoid floating-promise lint warnings

New exported types: BalancePair, BalanceFetchResult


src/__tests__/BalanceWatcher.test.ts

Expanded from 11 to 54 tests:

Group Tests
fetchBalance Happy path, zero balance, i128 max value, simulation error, no result, non-bigint retval, network rejection, passphrase auto-fetch, passphrase caching
fetchBalances All succeed, per-pair error capture without abort, empty input, single pair
getLastKnownBalance Before poll, after poll, unwatched pair, after clear, reflects latest poll
watch / unwatch Change detection, no-change suppression, change on second poll, multiple callbacks, multi-pair independence, update shape (address/token/balance/timestamp)
unwatch Removes specific callback, leaves others intact, removes watcher entry, stops polling when last watcher gone, double-unsub is safe
watchStream Registers two watchers, sender callback, recipient callback, unsub removes both, correct token used
onError Invoked on failure, correct args, safe if handler throws, per-pair isolation
Lifecycle isActive, start/stop/clear, idempotency, getWatcherCount, interval timing

Usage example

const watcher = new BalanceWatcher({
  rpcUrl: 'https://soroban-testnet.stellar.org',
  networkPassphrase: Networks.TESTNET,
  pollInterval: 3000,
  onError: (err, address, token) =>
    console.warn(`Balance fetch failed for ${address}/${token}:`, err.message),
});

// Watch both parties of a stream with one call
const unsub = watcher.watchStream(stream, (update) => {
  console.log(`${update.address}: ${update.balance} (token ${update.token})`);
});

// Bulk fetch without polling
const results = await watcher.fetchBalances([
  { address: sender,    token: tokenContract },
  { address: recipient, token: tokenContract },
]);

// Read cache without RPC
const cached = watcher.getLastKnownBalance(sender, tokenContract);

// Tear down
unsub();
watcher.clear();

Testing

pnpm --filter @fundable/sdk test

Summary by CodeRabbit

  • New Features

    • Balance watching now supports tracking multiple address/token pairs at once, cached last-known balances, and watching both sides of a payment stream with one callback.
    • Added a bulk balance lookup that returns results for each pair without failing the whole request.
    • Added configurable error handling for balance polling failures.
  • Bug Fixes

    • Improved watcher lifecycle behavior, including cleaner unsubscribe handling, better polling start/stop behavior, and more reliable callback notifications.

…l#182)

Replaces the minimal stub in test-utils/mockRpcServer.ts with a
fully-typed, reusable mock that covers every Soroban RPC method
used by the SDK. Tests no longer depend on any network connection.

Changes:
- Expand mockRpcServer.ts to mock all 9 RPC methods: getAccount,
  simulateTransaction, sendTransaction, getTransaction, getNetwork,
  getLatestLedger, getLedger, getFeeStats, getEvents
- Add 12 pre-canned scenario helpers (rpc.scenarios.*) for the most
  common test situations (success, pendingThenSuccess, simulationError,
  transactionFailed, accountNotFound, networkError, etc.)
- Intercept both @stellar/stellar-sdk/rpc and @stellar/stellar-sdk
  import paths via vi.mock() so all SDK modules are covered
- Add resetMockRpcServer() that clears call history and return values
  for clean test isolation in beforeEach
- Add mockRpcServer.test.ts with 49 tests covering the mock contract
- Fix missing mockTxNone helper in DistributorClient.test.ts and
  PaymentStreamClient.test.ts (caused ReferenceError at runtime)

Closes Fundable-Protocol#182
…Fundable-Protocol#181)

Enhances BalanceWatcher with the full feature set described in issue Fundable-Protocol#181:

New capabilities:
- watchStream(stream, callback) — subscribes to both sender and recipient
  of a payment stream in a single call; returns a single unsub function
  that tears down both subscriptions
- fetchBalances(pairs) — bulk-fetches multiple address/token pairs in
  parallel; failures are captured per-pair instead of aborting the batch
- getLastKnownBalance(address, token) — reads the cached balance from the
  last successful poll without making any RPC call
- onError option — replaces the silent console.error with a typed callback
  so callers control error handling
- unwatch(address, token, callback) — explicit unsubscribe method in
  addition to the returned unsub closure

Improvements to existing behaviour:
- Polling errors for one pair no longer silently swallow; they route to
  onError (or console.error as fallback) while other pairs keep polling
- start() guards against duplicate intervals (idempotent)
- stop() is idempotent — calling it when already stopped does not throw
- void operator on pollBalances() call to satisfy no-floating-promises

Tests (BalanceWatcher.test.ts) — expanded from 11 to 54 tests covering:
- fetchBalance: happy path, zero balance, i128 max, error response,
  no result, non-bigint retval, network rejection, passphrase caching
- fetchBalances: all pairs succeed, per-pair error capture, empty input
- getLastKnownBalance: before poll, after poll, after clear, multi-poll
- watch/unwatch: change detection, no-change suppression, multiple
  callbacks, multi-pair independence, update shape validation
- watchStream: sender + recipient registration, per-party callbacks,
  unsubscribe clears both, correct token forwarding
- onError: invocation, correct args, handler throws safely, per-pair
  isolation (error in one pair does not block others)
- Lifecycle: isActive, start/stop/clear, idempotency, interval timing

Closes Fundable-Protocol#181
@drips-wave

drips-wave Bot commented Jun 27, 2026

Copy link
Copy Markdown

@mandyslovestories-sudo Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR rewrites the shared mock RPC server, expands BalanceWatcher with stream watching, batch fetching, cached reads, and configurable poll-error handling, and adds undefined-result transaction test helpers.

Changes

Mock RPC server overhaul

Layer / File(s) Summary
Typed RPC model and fixtures
packages/sdk/src/test-utils/mockRpcServer.ts
RPC response types and default fixtures now model simulation, transaction, account, network, ledger, fee, and event data.
Mock instance, scenarios, and wiring
packages/sdk/src/test-utils/mockRpcServer.ts
The shared mock RPC server now exposes typed RPC methods, scenario helpers, selective reset behavior, and both SDK RPC entry points.
Mock RPC server tests
packages/sdk/src/__tests__/mockRpcServer.test.ts
The mock RPC server suite checks constructors, API helpers, reset behavior, scenario outcomes, override precedence, and mock-function compatibility.

BalanceWatcher polling and cache

Layer / File(s) Summary
Watcher contract and registration
packages/sdk/src/utils/BalanceWatcher.ts
BalanceWatcherOptions adds onError, watcher records store callback sets and cached balances, and watch()/unwatch() manage registration and polling state.
Stream, batch, and cache APIs
packages/sdk/src/utils/BalanceWatcher.ts
watchStream(), fetchBalances(), and getLastKnownBalance() add stream subscriptions, parallel batch fetches, and cache reads.
Polling lifecycle and error routing
packages/sdk/src/utils/BalanceWatcher.ts
start(), clear(), fetchBalance(), and pollBalances() update interval handling, balance updates, and error routing.
BalanceWatcher tests
packages/sdk/src/__tests__/BalanceWatcher.test.ts
The BalanceWatcher suite covers fetchBalance, fetchBalances, cached reads, watch and watchStream behavior, onError, and lifecycle transitions.

Undefined-result transaction mocks

Layer / File(s) Summary
Undefined-result transaction helpers
packages/sdk/src/__tests__/DistributorClient.test.ts, packages/sdk/src/__tests__/PaymentStreamClient.test.ts
mockTxNone() returns assembled transaction mocks with result: undefined in two client test files.

Sequence Diagram(s)

sequenceDiagram
  participant BalanceWatcher
  participant RpcServer as rpc.Server
  participant BalanceCallback
  participant OnError as onError

  BalanceWatcher->>BalanceWatcher: watch(address, token, callback)
  BalanceWatcher->>BalanceWatcher: pollBalances()
  BalanceWatcher->>RpcServer: fetchBalance(address, token)
  RpcServer-->>BalanceWatcher: simulation result or error
  alt balance changed
    BalanceWatcher->>BalanceCallback: BalanceUpdate
  end
  opt fetch failure
    BalanceWatcher->>OnError: error, address, token
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

A bunny hopped through RPC gleam,
And cached each balance in a stream.
With tiny paws, I clicked “poll” twice,
Then watched the ledgers sparkle nice. 🐇
No error grump could break my spring!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the new BalanceWatcher utility and matches the main SDK change.
Linked Issues check ✅ Passed The PR implements a balance-watching helper for token/address pairs, stream watching, polling, caching, and unsubscribe behavior requested in #181.
Out of Scope Changes check ✅ Passed The additional mock and test updates directly support BalanceWatcher behavior and its coverage, with no clear unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/sdk/src/__tests__/BalanceWatcher.test.ts

Parsing error: Cannot read file '/tsconfig.json'.

packages/sdk/src/__tests__/DistributorClient.test.ts

Parsing error: Cannot read file '/tsconfig.json'.

packages/sdk/src/__tests__/PaymentStreamClient.test.ts

Parsing error: Cannot read file '/tsconfig.json'.

  • 3 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (2)
packages/sdk/src/__tests__/PaymentStreamClient.test.ts (1)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delegate to mockTx() to avoid duplication.

mockTxNone duplicates mockTx() since mockTx already defaults result to undefined. Have it call mockTx() instead.

♻️ Proposed change
-const mockTxNone = () => ({ result: undefined, signAndSend: mockSignAndSend });
+const mockTxNone = () => mockTx();

Note: DistributorClient.test.ts uses a fresh vi.fn() per mockTx call, while this file shares mockSignAndSend. Consider aligning the two files for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/__tests__/PaymentStreamClient.test.ts` at line 15,
`mockTxNone` in `PaymentStreamClient.test.ts` duplicates `mockTx()` because
`mockTx()` already defaults `result` to `undefined`; update `mockTxNone` to
delegate to `mockTx()` instead of constructing the object manually. Keep the
shared `mockSignAndSend` behavior in this file, and align the helper pattern
with `mockTx`/`mockTxNone` so the test utilities stay consistent and avoid
duplicated setup.
packages/sdk/src/__tests__/DistributorClient.test.ts (1)

13-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Eliminate redundancy with mockTx().

mockTxNone is functionally identical to mockTx() since mockTx already defaults result to undefined. Delegate to mockTx to avoid a second source of truth for the same mock shape.

♻️ Proposed change
-const mockTxNone = () => ({ result: undefined, signAndSend: vi.fn() });
+const mockTxNone = () => mockTx();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/__tests__/DistributorClient.test.ts` around lines 13 - 14,
The mock helper mockTxNone in DistributorClient.test.ts is redundant because it
duplicates the default behavior already provided by mockTx. Update mockTxNone to
delegate to mockTx instead of constructing its own object so there is only one
source of truth for the mock shape and default undefined result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/sdk/src/__tests__/BalanceWatcher.test.ts`:
- Around line 76-90: The BalanceWatcher tests still allow the automatic initial
poll triggered by watch() to run, so manual poll() calls can overlap and consume
mocks unexpectedly. Update the test setup around the BalanceWatcher helper and
the watch()/pollBalances flow to explicitly account for or await the initial
poll before invoking poll(), using the BalanceWatcher mock helpers and the
poll(watcher) helper so deterministic tests don’t have two in-flight polls.
- Around line 606-614: The BalanceWatcher test for repeated start() calls is
only checking isActive() and watcher count, which does not directly prove there
is a single scheduled interval. Update the test around makeWatcher(), watch(),
and start() to assert the fake-timer count or spy on setInterval so you verify
only one interval is created even when start() is called multiple times.

In `@packages/sdk/src/test-utils/mockRpcServer.ts`:
- Around line 444-453: The resetMockRpcServer helper is only resetting object
values, so the top-level vi.fn() RPC mocks are skipped and their call state
leaks between tests. Update resetMockRpcServer to iterate over the mock entries
and reset function-valued mocks (using mockReset on vi.fn() functions) while
still excluding the scenarios container, and keep the logic localized to
resetMockRpcServer and the mock object it walks.

In `@packages/sdk/src/utils/BalanceWatcher.ts`:
- Around line 297-302: The BalanceWatcher polling loop can overlap and let a
slower, older poll overwrite newer balance data. Update the polling flow in
BalanceWatcher.pollBalances() and the setInterval startup path to serialize
requests or discard stale results using a monotonic sequence/token so only the
latest poll may update lastBalance and emit balance changes. Keep the initial
immediate poll and all interval-triggered polls coordinated through the same
guard so updates cannot arrive out of order.
- Around line 436-438: The fallback error log in BalanceWatcher should not print
full address/token identifiers by default. Update the console.error path in
BalanceWatcher’s balance-fetch error handling to redact or partially mask
record.address and record.token, and only emit full identifiers when explicitly
opted in through onError or a similar caller-controlled flag. Keep the error
object logging, but make the identifier formatting privacy-safe in this fallback
branch.
- Around line 180-182: The watch flow in BalanceWatcher.watch currently only
calls start() when isRunning is false, so newly added address/token pairs are
not polled immediately if the watcher is already active. Update watch() so it
triggers an immediate poll for the newly registered pair regardless of
isRunning, while preserving the existing running state and interval loop; use
the watch() and start() logic in BalanceWatcher to locate and adjust this
behavior.

---

Nitpick comments:
In `@packages/sdk/src/__tests__/DistributorClient.test.ts`:
- Around line 13-14: The mock helper mockTxNone in DistributorClient.test.ts is
redundant because it duplicates the default behavior already provided by mockTx.
Update mockTxNone to delegate to mockTx instead of constructing its own object
so there is only one source of truth for the mock shape and default undefined
result.

In `@packages/sdk/src/__tests__/PaymentStreamClient.test.ts`:
- Line 15: `mockTxNone` in `PaymentStreamClient.test.ts` duplicates `mockTx()`
because `mockTx()` already defaults `result` to `undefined`; update `mockTxNone`
to delegate to `mockTx()` instead of constructing the object manually. Keep the
shared `mockSignAndSend` behavior in this file, and align the helper pattern
with `mockTx`/`mockTxNone` so the test utilities stay consistent and avoid
duplicated setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c73757f-f12a-450a-9ab1-b599e40929cc

📥 Commits

Reviewing files that changed from the base of the PR and between c085d63 and bff5dcf.

📒 Files selected for processing (6)
  • packages/sdk/src/__tests__/BalanceWatcher.test.ts
  • packages/sdk/src/__tests__/DistributorClient.test.ts
  • packages/sdk/src/__tests__/PaymentStreamClient.test.ts
  • packages/sdk/src/__tests__/mockRpcServer.test.ts
  • packages/sdk/src/test-utils/mockRpcServer.ts
  • packages/sdk/src/utils/BalanceWatcher.ts

Comment on lines +76 to +90
pollInterval: 60_000, // long interval — prevents auto-polls interfering
...opts,
});
}

function mockSuccess(balance: bigint) {
/** Wire up the SDK mocks so that fetchBalance(ADDRESS, TOKEN) returns `balance`. */
function mockBalanceSuccess(balance: bigint) {
const { scValToNative } = require('@stellar/stellar-sdk');
mockSimulateTransaction.mockResolvedValue({
result: { retval: {} },
});
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} } });
(scValToNative as ReturnType<typeof vi.fn>).mockReturnValue(balance);
}

/** Shorthand: manually invoke the private pollBalances method. */
function poll(watcher: BalanceWatcher): Promise<void> {
return (watcher as unknown as { pollBalances(): Promise<void> }).pollBalances();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Account for the automatic initial poll in deterministic tests.

The long interval does not prevent watch() from immediately calling pollBalances(). Tests that call the private poll() after watch() can have an extra in-flight poll consuming mock return values or invoking callbacks unexpectedly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/__tests__/BalanceWatcher.test.ts` around lines 76 - 90, The
BalanceWatcher tests still allow the automatic initial poll triggered by watch()
to run, so manual poll() calls can overlap and consume mocks unexpectedly.
Update the test setup around the BalanceWatcher helper and the
watch()/pollBalances flow to explicitly account for or await the initial poll
before invoking poll(), using the BalanceWatcher mock helpers and the
poll(watcher) helper so deterministic tests don’t have two in-flight polls.

Comment on lines +606 to +614
it('calling start() multiple times does not create duplicate intervals', () => {
vi.useFakeTimers();
const watcher = makeWatcher();
watcher.watch(ADDRESS, TOKEN, vi.fn());
watcher.start(); // already running from watch()
watcher.start(); // should be a no-op
// Only one interval should exist — validated via getWatcherCount staying stable
expect(watcher.isActive()).toBe(true);
watcher.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate lifecycle timer tests and available Vitest timer assertions in this file.
rg -n -C3 'useFakeTimers|getTimerCount|setInterval|start\\(\\)' packages/sdk/src/__tests__/BalanceWatcher.test.ts

Repository: Fundable-Protocol/stellar_client_os

Length of output: 776


Assert the timer count directly here. isActive() and watcher counts don’t prove only one scheduled interval; check the fake-timer count or spy on setInterval so repeated start() calls can’t silently add duplicates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/__tests__/BalanceWatcher.test.ts` around lines 606 - 614,
The BalanceWatcher test for repeated start() calls is only checking isActive()
and watcher count, which does not directly prove there is a single scheduled
interval. Update the test around makeWatcher(), watch(), and start() to assert
the fake-timer count or spy on setInterval so you verify only one interval is
created even when start() is called multiple times.

Comment on lines +444 to +453
export function resetMockRpcServer(): void {
(Object.values(mock) as unknown[]).forEach((value) => {
if (value !== null && typeof value === 'object' && !('scenarios' in (value as object))) {
const fn = value as { mockReset?: () => void };
if (typeof fn.mockReset === 'function') {
fn.mockReset();
}
}
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Reset the top-level vi.fn() methods.

Line 446 filters for typeof value === 'object', but every RPC mock is a vi.fn() function, so none of the mocks are reset. Call history and resolved values will leak between tests.

Proposed fix
 export function resetMockRpcServer(): void {
-  (Object.values(mock) as unknown[]).forEach((value) => {
-    if (value !== null && typeof value === 'object' && !('scenarios' in (value as object))) {
-      const fn = value as { mockReset?: () => void };
-      if (typeof fn.mockReset === 'function') {
-        fn.mockReset();
-      }
+  Object.entries(mock).forEach(([key, value]) => {
+    if (key === 'scenarios') {
+      return;
+    }
+
+    const fn = value as { mockReset?: () => void };
+    if (typeof fn.mockReset === 'function') {
+      fn.mockReset();
     }
   });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function resetMockRpcServer(): void {
(Object.values(mock) as unknown[]).forEach((value) => {
if (value !== null && typeof value === 'object' && !('scenarios' in (value as object))) {
const fn = value as { mockReset?: () => void };
if (typeof fn.mockReset === 'function') {
fn.mockReset();
}
}
});
}
export function resetMockRpcServer(): void {
Object.entries(mock).forEach(([key, value]) => {
if (key === 'scenarios') {
return;
}
const fn = value as { mockReset?: () => void };
if (typeof fn.mockReset === 'function') {
fn.mockReset();
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/test-utils/mockRpcServer.ts` around lines 444 - 453, The
resetMockRpcServer helper is only resetting object values, so the top-level
vi.fn() RPC mocks are skipped and their call state leaks between tests. Update
resetMockRpcServer to iterate over the mock entries and reset function-valued
mocks (using mockReset on vi.fn() functions) while still excluding the scenarios
container, and keep the logic localized to resetMockRpcServer and the mock
object it walks.

Comment on lines 180 to 182
if (!this.isRunning) {
this.start();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Poll newly added pairs immediately even when the watcher is already running.

watch() only triggers an immediate poll through start() when isRunning is false. Adding a new address/token pair while another pair is already being watched waits until the next interval, despite the method contract saying the pair is polled immediately.

Proposed direction
+    const isNewPair = !this.watchers.has(key);
+
-    if (!this.watchers.has(key)) {
+    if (isNewPair) {
       this.watchers.set(key, {
         address,
         token,
         lastBalance: null,
         callbacks: new Set(),
       });
     }
@@
     if (!this.isRunning) {
       this.start();
+    } else if (isNewPair) {
+      void this.pollBalances();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!this.isRunning) {
this.start();
}
if (!this.isRunning) {
this.start();
} else if (isNewPair) {
void this.pollBalances();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/utils/BalanceWatcher.ts` around lines 180 - 182, The watch
flow in BalanceWatcher.watch currently only calls start() when isRunning is
false, so newly added address/token pairs are not polled immediately if the
watcher is already active. Update watch() so it triggers an immediate poll for
the newly registered pair regardless of isRunning, while preserving the existing
running state and interval loop; use the watch() and start() logic in
BalanceWatcher to locate and adjust this behavior.

Comment on lines 297 to +302
this.intervalId = setInterval(() => {
this.pollBalances();
void this.pollBalances();
}, this.pollInterval);

// Initial poll
this.pollBalances();
// Initial poll immediately so callers get a balance update right away
void this.pollBalances();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Prevent overlapping poll cycles from racing the cache backward.

Each interval fires pollBalances() without waiting for the previous cycle. If an older, slower RPC response resolves after a newer one, it can overwrite lastBalance with stale data and emit out-of-order balance updates.

Proposed direction
+  private pollInFlight = false;
+
   private async pollBalances(): Promise<void> {
+    if (this.pollInFlight) return;
+    this.pollInFlight = true;
+    try {
       const promises = Array.from(this.watchers.values()).map(
         async (record) => {
           // existing per-record polling logic
         },
       );
 
       await Promise.allSettled(promises);
+    } finally {
+      this.pollInFlight = false;
+    }
   }

Also applies to: 400-445

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/utils/BalanceWatcher.ts` around lines 297 - 302, The
BalanceWatcher polling loop can overlap and let a slower, older poll overwrite
newer balance data. Update the polling flow in BalanceWatcher.pollBalances() and
the setInterval startup path to serialize requests or discard stale results
using a monotonic sequence/token so only the latest poll may update lastBalance
and emit balance changes. Keep the initial immediate poll and all
interval-triggered polls coordinated through the same guard so updates cannot
arrive out of order.

Comment on lines +436 to +438
console.error(
`BalanceWatcher: error fetching balance for ${record.address}:${record.token}:`,
err,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging full address/token identifiers by default.

Address/token pairs can identify user holdings or activity. Prefer redaction in the fallback log path, or require callers to opt into full identifiers through onError.

Proposed fix
             console.error(
-              `BalanceWatcher: error fetching balance for ${record.address}:${record.token}:`,
+              "BalanceWatcher: error fetching balance for watched pair:",
               err,
             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.error(
`BalanceWatcher: error fetching balance for ${record.address}:${record.token}:`,
err,
console.error(
"BalanceWatcher: error fetching balance for watched pair:",
err,
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 435-438: Avoid logging sensitive data
Context: console.error(
BalanceWatcher: error fetching balance for ${record.address}:${record.token}:,
err,
)
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/utils/BalanceWatcher.ts` around lines 436 - 438, The
fallback error log in BalanceWatcher should not print full address/token
identifiers by default. Update the console.error path in BalanceWatcher’s
balance-fetch error handling to redact or partially mask record.address and
record.token, and only emit full identifiers when explicitly opted in through
onError or a similar caller-controlled flag. Keep the error object logging, but
make the identifier formatting privacy-safe in this fallback branch.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SDK] Add utility class

1 participant