feat(frontend): inform holders of 1Sec-bridged balances - #13933
Draft
sbpublic wants to merge 8 commits into
Draft
Conversation
Offer only the leg of each 1Sec pair that returns a token to the chain it is native to, so the wallet no longer routes a user into a bridged position while balances already held keep a way back out. The direction to keep is per token, not per chain, because 1Sec wraps both ways: `evmMode` records the chain a token is native to, so `minter` tokens (ICP, BOB, GLDT, ckBTC) keep EVM->ICP and `locker` tokens (USDC, USDT, cbBTC) keep ICP->EVM. Reading the classification from the config rather than hardcoding a token list keeps a token 1Sec adds later correct without a code change. Enforced once, in `oneSecCompatibleDestinations`: it is the provider's `getSupportedDestinations`, so the destination token list and the destination network filter both narrow from the same data. The pay-side lists are left broad on purpose — narrowing them could only hide a disabled token from the pay list, the opposite of the goal. Behind `ONESEC_UNWRAP_ONLY`, matching the existing provider kill-switch convention, so both directions come back by flipping one const.
The spec records why the wrapped ERC-20 of ICP cannot simply be dropped (it is a suggested token, so a holder may have no custom-token entry for it), and the facts established by querying the 1Sec canister directly: CHAT is still bridged by 1Sec but has never shipped in the `onesec-bridge` package, so it is unreachable in OISY and excluded here.
Adds `isOneSecWrappedToken`, the holder's-eye view of the direction rule: the ICRC ledger is the wrapped side of an EVM-native (`locker`) token and the ERC-20 is the wrapped side of an ICP-native (`minter`) token, so the same OneSec config drives both the swap gate and this check. The EVM branch is bounded to the chains OneSec bridges, because a `minter` token reuses one contract address across all of them — without the bound, an unrelated token deployed at that address on another chain would match. `oneSecBridgedTokensWithBalance` pairs that with the balances store. Scoped to enabled tokens, since that is the set balances load for, and empty while OneSec swaps are off: with no swap available there would be no action to point the user at. CHAT needs no special case — it is absent from the config, so it never matches.
A dismissible warning at the top of the page names the bridged tokens
the user holds and recommends swapping them back to their native
network, so a holder is told the exit exists rather than having to open
each token to find out.
Reuses the `$token_list` / `formatList` pattern of the Activity page's
index-canister warnings, but qualifies each entry by network
("ICP (Base)"): a wrapped token shares its symbol with the native token
the user should hold, so a bare "ICP" would name the wrong thing.
Dismissal is held in the component rather than delegated to MessageBox,
so it survives the store re-emitting and remounting the box, and lasts
for the session only — the warning is about the balance in front of the
user, so it returns while that balance is still there and disappears on
its own once it reaches zero.
…end/onesec-bridged-balance-warning
…end/onesec-bridged-balance-warning
Base automatically changed from
impr/frontend/onesec-unwrap-only-swaps
to
main
September 8, 2026 14:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Stacked on #13928, which restricts 1Sec swaps to the unwrapping direction. That closes the way in, but says nothing to the users who already hold a bridged balance — and many of them never added the token deliberately, since the wrapped ERC-20 of ICP is a suggested token on Ethereum, Base and Arbitrum. This PR tells them the exit exists, rather than leaving them to open each token to find out.
Changes
isOneSecWrappedToken, the holder's-eye view of feat(frontend): restrict 1Sec swaps to the unwrapping direction #13928's direction rule: the ICRC ledger is the wrapped side of an EVM-native (locker) token, the ERC-20 is the wrapped side of an ICP-native (minter) token. The same OneSec config drives both the swap gate and this check, so the two cannot drift.mintertoken reuses one contract address across all of them, so without the bound an unrelated token deployed at that address on another chain would match.oneSecBridgedTokensWithBalance, pairing that check with the balances store. Scoped to enabled tokens, since that is the set balances load for, and empty while OneSec swaps are off — with no swap available there would be no action to point the user at.OneSecBridgedBalanceWarning, a dismissibleMessageBox level="warning"mounted at the top of the page content, naming the tokens and recommending they be swapped back to their native network.$token_list/formatListpattern of the Activity page's index-canister warnings, but adds the network qualifier those do not need: a wrapped token here shares its symbol with the native token the user should be holding, so a bare "ICP" would name precisely the wrong thing.MessageBox, so it survives the store re-emitting and remounting the box. It lasts for the session only and is not stored in the user's profile — the warning is about the balance in front of the user, so it returns while that balance is still there, and disappears on its own once it reaches zero.ar.jsonis left empty on purpose — it is not in theLanguagesenum, so it is unreachable at runtime.docs/ai/PRODUCT.md.Tests
npm run format,npm run lint(0 errors, 0 warnings),npm run check(6010 files, 0 errors) andnpm run check:tests(7304 files, 0 errors) all pass.onesec-swap.utils.spec.ts(80 tests) coversisOneSecWrappedTokenper token and per side: true for the wrapped side of both aminterand alockerpair, false for either native side, false for an unknown ledger or address, false for CHAT, false on a network OneSec does not bridge even at a wrapped address, and case-insensitive on the address.onesec-bridged-balances.derived.spec.ts(7 tests) covers a held wrapped ERC-20, a wrapped ICRC ledger, a zero balance, a balance that has not loaded, an EVM-native token the bridge merely supports, an ICP-native ICRC ledger, and the empty case.OneSecBridgedBalanceWarning.spec.ts(5 tests) covers not rendering with nothing held, rendering with a held token, the network-qualified label, several tokens joined into one localized list, and staying dismissed once closed.routes/,tokens.derived,tokens-ui.derivedand the swap specs pass unchanged.onesec-swap.services.spec.tshas 14localStorage.clear is not a functionfailures, androutes/reports 2 unhandled errors originating in the root+layout.svelteand the samelocalStorageclash.MessageBox level="warning"with the same props as the existing Activity-page warnings, andMessageBoxcarries its ownmb-4, so no wrapper was needed for spacing — but a screenshot in the running app is still worth having before merge.Divergence from the spec
The spec said the warning would name the affected tokens; it did not specify how. Implementation added the network qualifier described above, and the spec has been updated to record that decision and why it differs from the pattern it borrows from.
🤖 Implemented with Claude Code — Claude Opus 5 (
claude-opus-5)