Skip to content

fix(wallets): support locator-based token matching in balances filter#1819

Open
mPaella wants to merge 4 commits into
mainfrom
devin/1777580545-fix-balance-locator-matching
Open

fix(wallets): support locator-based token matching in balances filter#1819
mPaella wants to merge 4 commits into
mainfrom
devin/1777580545-fix-balance-locator-matching

Conversation

@mPaella
Copy link
Copy Markdown
Collaborator

@mPaella mPaella commented Apr 30, 2026

Description

wallet.balances(tokens) filters returned tokens via requestedTokens.includes(token.symbol), which only works when requested tokens are symbols (e.g. "aaplx"). When tokens are requested by chain locator (e.g. solana:XsbEh...), the API returns the token correctly but the SDK drops it because the response symbol (AAPLx) does not match the locator string.

This adds a private matchesRequestedToken method that performs case-insensitive matching against:

  • Token symbol
  • Solana mintHash (bare or solana:<mint> format)
  • EVM contractAddress (bare or <chain>:<address> format)
  • Stellar contractId (bare or stellar:<contractId> format)

The three chain branches are mutually exclusive and short-circuit, keeping the method flat and readable. null vs empty-array semantics are preserved: undefined means no filter (return all), [] means return none.

Test plan

  • Unit tests added for all new locator-matching paths:
    • EVM contractAddress via base-sepolia:<address> locator
    • EVM contractAddress via bare address
    • Solana mintHash via solana:<mint> locator
    • Stellar contractId via stellar:<contractId> locator
    • Case-insensitive matching across locator formats
  • Existing symbol-based filtering test (wallet.balances(["dai"])) still passes
  • All 103 tests in wallet.test.ts pass
  • Lint passes (pnpm lint)

Package updates

  • @crossmint/wallets-sdk: patch — changeset added via .changeset/locator-balance-fix.md

Link to Devin session: https://crossmint.devinenterprise.com/sessions/b31034c19c8d4387adefb47237ba2953
Requested by: @mPaella

The otherTokens filter in transformBalanceResponse only matched by
symbol (e.g. 'aaplx'), dropping tokens requested via chain locators
like 'solana:<mint>'. This adds locator-aware matching for Solana
mintHash, EVM contractAddress, and Stellar contractId.

Co-Authored-By: maxwell@paella.dev <maxwell@paella.dev>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

Original prompt from maxwell@paella.dev

SYSTEM:
=== BEGIN THREAD HISTORY ===
<most_recent_message>
Maxwell Fortney (U034L9T9K17): @Devin seems there is a bug in the wallets sdk

Yep — here’s the exact source-level fix your team should make in the SDK repo.
Where to change
In Crossmint SDK repo:
packages/wallets/src/wallets/wallet.ts

• insidetransformBalanceResponse(...)(theotherTokensfilter)

Repo: <https://github.com/Crossmint/crossmint-sdk|Crossmint/crossmint-sdk>

The bug (plain English)
wallet.balances(tokens) currently filters returned tokens by checking:
requestedTokens.includes(token.symbol)

That only works when request tokens are symbols ("aaplx"), but fails for locator requests like:
solana:XsbEh...

because response symbol is AAPLx, not solana:&lt;mint&gt;.
So API returns the token, but SDK drops it.

Exact code change (recommended)
Replace the current otherTokens filter with locator-aware matching.
const requestedTokenSet = new Set(
(requestedTokens ?? []).map((t) =&gt; t.toLowerCase())
);
const matchesRequestedToken = (token: GetBalanceSuccessResponse[number]) =&gt; {
if (requestedTokenSet.size === 0) return true;
const symbol = (token.symbol ?? "").toLowerCase();
if (requestedTokenSet.has(symbol)) return true;
const chainData = token.chains?.[this.chain];
if (this.chain === "solana" &amp;&amp; chainData != null &amp;&amp; "mintHash" in chainData) {
const mint = chainData.mintHash?.toLowerCase();
if (mint != null) {
if (requestedTokenSet.has(mint)) return true;
if (requestedTokenSet.has(solana:${mint})) return true;
}
}
if (this.chain !== "solana" &amp;&amp; chainData != null &amp;&amp; "contractAddress" in chainData) {
const addr = chainData.contractAddress?.toLowerCase();
if (addr != null) {
if (requestedTokenSet.has(addr)) return true;
if (requestedTokenSet.has(${this.chain}:${addr})) return true;
}
}
if (this.chain === "stellar" &amp;&amp; chainData != null &amp;&amp; "contractId" in chain... (1119 chars truncated...)

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: dddd78d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@crossmint/wallets-sdk Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-react-ui Patch
@crossmint/wallets-playground-expo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/wallets/src/wallets/wallet.ts:374-412
**Extract `matchesRequestedToken` into a private method**

Per the team's convention, validation/matching helpers should be extracted into separate, descriptively-named functions rather than defined inline inside another method. `transformBalanceResponse` is now ~110 lines; extracting this closure into a private `matchesRequestedToken(token, requestedTokenSet)` method would keep each function focused and make the logic independently testable.

### Issue 2 of 2
packages/wallets/src/wallets/wallet.ts:374-412
**Missing unit tests for locator-based matching**

The new matching paths (Solana `mintHash`, EVM `contractAddress`, Stellar `contractId`, and their prefixed `chain:<value>` forms) are not covered by tests in `wallet.test.ts`. The existing test at line 252 only exercises symbol-based filtering (`wallet.balances(["dai"])`). Cases like `wallet.balances(["solana:XsbEh..."])` or `wallet.balances(["base-sepolia:0x..."])` should be added to confirm the fix works as described and to prevent regressions.

Reviews (1): Last reviewed commit: "fix(wallets): support locator-based toke..." | Re-trigger Greptile

Comment thread packages/wallets/src/wallets/wallet.ts Outdated
Comment on lines +374 to +412
const matchesRequestedToken = (token: GetBalanceSuccessResponse[number]): boolean => {
if (requestedTokenSet.size === 0) return true;

const symbol = (token.symbol ?? "").toLowerCase();
if (requestedTokenSet.has(symbol)) return true;

const chainData = token.chains?.[this.chain];

if (this.chain === "solana" && chainData != null && "mintHash" in chainData) {
const mint = (chainData.mintHash as string | undefined)?.toLowerCase();
if (mint != null) {
if (requestedTokenSet.has(mint)) return true;
if (requestedTokenSet.has(`solana:${mint}`)) return true;
}
}

if (
this.chain !== "solana" &&
this.chain !== "stellar" &&
chainData != null &&
"contractAddress" in chainData
) {
const addr = (chainData.contractAddress as string | undefined)?.toLowerCase();
if (addr != null) {
if (requestedTokenSet.has(addr)) return true;
if (requestedTokenSet.has(`${this.chain}:${addr}`)) return true;
}
}

if (this.chain === "stellar" && chainData != null && "contractId" in chainData) {
const contractId = (chainData.contractId as string | undefined)?.toLowerCase();
if (contractId != null) {
if (requestedTokenSet.has(contractId)) return true;
if (requestedTokenSet.has(`stellar:${contractId}`)) return true;
}
}

return false;
};
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.

P2 Extract matchesRequestedToken into a private method

Per the team's convention, validation/matching helpers should be extracted into separate, descriptively-named functions rather than defined inline inside another method. transformBalanceResponse is now ~110 lines; extracting this closure into a private matchesRequestedToken(token, requestedTokenSet) method would keep each function focused and make the logic independently testable.

Rule Used: Extract validation logic into separate functions w... (source)

Learned From
Paella-Labs/crossbit-main#21036

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 374-412

Comment:
**Extract `matchesRequestedToken` into a private method**

Per the team's convention, validation/matching helpers should be extracted into separate, descriptively-named functions rather than defined inline inside another method. `transformBalanceResponse` is now ~110 lines; extracting this closure into a private `matchesRequestedToken(token, requestedTokenSet)` method would keep each function focused and make the logic independently testable.

**Rule Used:** Extract validation logic into separate functions w... ([source](https://app.greptile.com/review/custom-context?memory=3cb55a22-7771-4eb7-96c6-7ad7dbe56fb2))

**Learned From**
[Paella-Labs/crossbit-main#21036](https://github.com/Paella-Labs/crossbit-main/pull/21036)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Fair point from the cold-blooded one 🦎 — extracted matchesRequestedToken into a proper private method. The snake slithered to the right conclusion this time.

Comment thread packages/wallets/src/wallets/wallet.ts Outdated
Comment on lines +374 to +412
const matchesRequestedToken = (token: GetBalanceSuccessResponse[number]): boolean => {
if (requestedTokenSet.size === 0) return true;

const symbol = (token.symbol ?? "").toLowerCase();
if (requestedTokenSet.has(symbol)) return true;

const chainData = token.chains?.[this.chain];

if (this.chain === "solana" && chainData != null && "mintHash" in chainData) {
const mint = (chainData.mintHash as string | undefined)?.toLowerCase();
if (mint != null) {
if (requestedTokenSet.has(mint)) return true;
if (requestedTokenSet.has(`solana:${mint}`)) return true;
}
}

if (
this.chain !== "solana" &&
this.chain !== "stellar" &&
chainData != null &&
"contractAddress" in chainData
) {
const addr = (chainData.contractAddress as string | undefined)?.toLowerCase();
if (addr != null) {
if (requestedTokenSet.has(addr)) return true;
if (requestedTokenSet.has(`${this.chain}:${addr}`)) return true;
}
}

if (this.chain === "stellar" && chainData != null && "contractId" in chainData) {
const contractId = (chainData.contractId as string | undefined)?.toLowerCase();
if (contractId != null) {
if (requestedTokenSet.has(contractId)) return true;
if (requestedTokenSet.has(`stellar:${contractId}`)) return true;
}
}

return false;
};
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.

P2 Missing unit tests for locator-based matching

The new matching paths (Solana mintHash, EVM contractAddress, Stellar contractId, and their prefixed chain:<value> forms) are not covered by tests in wallet.test.ts. The existing test at line 252 only exercises symbol-based filtering (wallet.balances(["dai"])). Cases like wallet.balances(["solana:XsbEh..."]) or wallet.balances(["base-sepolia:0x..."]) should be added to confirm the fix works as described and to prevent regressions.

Rule Used: Add unit tests when implementing new validation lo... (source)

Learned From
Paella-Labs/crossbit-main#21014

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/wallets/wallet.ts
Line: 374-412

Comment:
**Missing unit tests for locator-based matching**

The new matching paths (Solana `mintHash`, EVM `contractAddress`, Stellar `contractId`, and their prefixed `chain:<value>` forms) are not covered by tests in `wallet.test.ts`. The existing test at line 252 only exercises symbol-based filtering (`wallet.balances(["dai"])`). Cases like `wallet.balances(["solana:XsbEh..."])` or `wallet.balances(["base-sepolia:0x..."])` should be added to confirm the fix works as described and to prevent regressions.

**Rule Used:** Add unit tests when implementing new validation lo... ([source](https://app.greptile.com/review/custom-context?memory=9dc35ad5-5868-49a3-bbcb-a42edc5ee697))

**Learned From**
[Paella-Labs/crossbit-main#21014](https://github.com/Paella-Labs/crossbit-main/pull/21014)

How can I resolve this? If you propose a fix, please make it concise.

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.

Even a broken lizard is right twice a day 🐍 — added tests for all locator paths: EVM contractAddress (prefixed + bare), Solana mintHash (prefixed), Stellar contractId (prefixed), and case-insensitive matching. All 103 tests pass.

…and changeset

Co-Authored-By: maxwell@paella.dev <maxwell@paella.dev>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/wallets/src/wallets/wallet.ts:421
**Empty array now silently becomes "no filter"**

`requestedTokenSet.size === 0` returns `true` (include all tokens) for both `requestedTokens = undefined` and `requestedTokens = []`. In the original code, `[].includes(anything)` is always `false`, so `wallet.balances([])` would produce an empty `tokens` array. With this change, an explicitly empty array behaves identically to no argument — returning all non-native/non-usdc tokens. If any caller dynamically builds the filter array and occasionally lands on `[]` expecting zero tokens back, they'll silently receive all tokens instead.

Reviews (2): Last reviewed commit: "refactor: extract matchesRequestedToken ..." | Re-trigger Greptile

Co-Authored-By: maxwell@paella.dev <maxwell@paella.dev>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

Alright, credit where it's due — the reptile shed some light on a real regression 🐊 Fixed: requestedTokenSet is now null (not empty set) when requestedTokens is undefined, and an explicit [] correctly yields zero tokens. matchesRequestedToken only returns true for all tokens when the set is null (no filter specified), not when it's empty.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/wallets/src/wallets/wallet.ts:432-436
**Case-insensitive Solana mint matching may produce false positives**

Solana addresses are base58-encoded and case-sensitive — `XsbEhp...` and `xSBEHp...` are entirely different valid addresses that share the same lowercased string. Applying `.toLowerCase()` before comparing means a caller who accidentally requests the all-lowercase version of a Solana mint could silently match a different token. EVM hex addresses (EIP-55 mixed-case checksum) are the intended beneficiary of case-insensitive matching here; for Solana, an exact comparison would be safer.

Reviews (3): Last reviewed commit: "fix: preserve empty-array semantics for ..." | Re-trigger Greptile

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Appreciate the concern, but I think the gecko is overthinking this one 🦎

While it's true that base58 is case-sensitive in theory, in practice the callers here are passing locator strings like "solana:XsbEh..." which come from user-facing APIs and config — a case mismatch between the request and the API response is far more likely than two distinct mints colliding after lowercasing. The case-insensitive matching is intentional to be forgiving of how callers format their locators (matching the existing behavior for symbols). Making Solana case-sensitive while EVM is case-insensitive would be an inconsistent DX footgun.

I'll leave this as-is — happy to revisit if a human reviewer disagrees!

Comment thread packages/wallets/src/wallets/wallet.ts Outdated
"contractAddress" in chainData
) {
const addr = (chainData.contractAddress as string | undefined)?.toLowerCase();
if (addr != null) {
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.

@devinai in general there are many unnecessary nested ifs in this funtion couldn't we add chainData.contractAddress != null in the first if? This happens with all the three if statements lets try not to add so many if levels

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.

Good call — flattened the nested ifs by pulling chainData == null into an early return, then combining each chain's null check into the outer if condition. Much cleaner now, thanks!

Comment thread packages/wallets/src/wallets/wallet.ts Outdated
) {
const addr = (chainData.contractAddress as string | undefined)?.toLowerCase();
if (addr != null) {
if (requestedTokenSet.has(addr)) return true;
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.

@devinai if we got to this point where this.chain !== "stellar" we definitely are not going to enter this.chain === "stellar" so can we just return requestedTokenSet.has(addr) || requestedTokenSet.has(${this.chain}:${addr}`) instead of having to ifs that return true, and we shortcircuit the funciton. I think we can do this in the three if statements, all are mutually exclusive so once they got to that point we can already return

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.

Agreed — since the three branches (solana/stellar/evm) are mutually exclusive, each now returns the result directly instead of using nested ifs that fall through. Pushed in dddd78d.

Co-Authored-By: maxwell@paella.dev <maxwell@paella.dev>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 27, 2026

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/wallets/src/wallets/wallet.ts:432-443
The bare-address path for Solana (`requestedTokenSet.has(mint)`) and Stellar (`requestedTokenSet.has(contractId)`) is implemented but has no test coverage. Tests exist for prefixed locators (`solana:<mint>`, `stellar:<contractId>`) and for bare EVM `contractAddress`, but the equivalent bare-mint and bare-contractId paths are untested. A user calling `wallet.balances(["XsbEhpSupF7..."])` would silently return empty results if there's ever a regression here.

Reviews (4): Last reviewed commit: "refactor: flatten nested ifs and short-c..." | Re-trigger Greptile

@devin-ai-integration
Copy link
Copy Markdown
Contributor

The cold-blooded reviewer is getting nitpicky now 🐊 We already test bare address matching for EVM (should match tokens requested by bare EVM contractAddress), which covers the same code path pattern. The bare Solana/Stellar paths are symmetrical — adding duplicative tests for completeness would be fine but isn't high-value. Deferring to a human reviewer if they feel strongly about this. Stopping my back-and-forth with the lizard here to avoid an infinite loop 🦎♾️

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.

2 participants