diff --git a/merged-packages/stellar-wallet-snap/.depcheckrc.json b/merged-packages/stellar-wallet-snap/.depcheckrc.json
new file mode 100644
index 00000000..4cb1e349
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/.depcheckrc.json
@@ -0,0 +1,13 @@
+{
+ "ignores": [
+ "jest-transform-stub",
+ "ts-jest",
+ "@metamask/auto-changelog",
+ "@metamask/key-tree",
+ "@stellar/stellar-sdk",
+ "@types/lodash",
+ "async-mutex",
+ "bignumber.js",
+ "lodash"
+ ]
+}
diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example
new file mode 100644
index 00000000..875b64a0
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/.env.example
@@ -0,0 +1,80 @@
+# Use:
+# - local for local development
+# - test for running tests locally (mandatory)
+# - production before submitting a PR
+ENVIRONMENT=local
+
+# Use:
+# - all for all logs
+# - error for error logs
+# - warn for warn logs
+# - info for info logs
+# - debug for debug logs
+# - silent for silent logs
+LOG_LEVEL=all
+
+# Mainnet RPC URLs
+RPC_URL_MAINNET=https://mainnet.sorobanrpc.com
+
+# Mainnet Horizon URLs
+HORIZON_URL_MAINNET=https://horizon.stellar.org
+
+# Mainnet Explorer Base URLs
+EXPLORER_MAINNET_BASE_URL=https://stellar.expert/explorer/public
+
+# Testnet RPC URLs
+RPC_URL_TESTNET=https://soroban-testnet.stellar.org
+
+# Testnet Horizon URLs
+HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org
+
+# Testnet Explorer Base URLs
+EXPLORER_TESTNET_BASE_URL=https://stellar.expert/explorer/testnet
+
+# Token API Base URL
+TOKEN_API_BASE_URL=https://tokens.api.cx.metamask.io
+
+# Static API Base URL
+STATIC_API_BASE_URL=https://static.cx.metamask.io
+
+# Price API Base URL
+PRICE_API_BASE_URL=https://price.api.cx.metamask.io
+
+# Security Alerts API Base URL
+SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io
+
+# Cache TTL Milliseconds for base fee
+#BASE_FEE_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for load on chain account
+#LOAD_ON_CHAIN_ACCOUNT_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for SEP41 transfer simulate transaction
+#SIMULATE_TRANSACTION_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for sep41 asset balance
+#SEP41_ASSET_BALANCE_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for spot prices
+#SPOT_PRICES_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for fiat exchange rates
+#FIAT_EXCHANGE_RATES_TTL_MILLISECONDS=
+
+# Cache TTL Milliseconds for historical prices
+#HISTORICAL_PRICES_TTL_MILLISECONDS=
+
+# Inclusion fee multiplier (applied to Stellar network base fee per operation)
+#BASE_FEE_MULTIPLIER=
+
+# Maximum fee threshold in XLM
+#MAX_FEE_THRESHOLD_IN_XLM=
+
+# Maximum background reschedules for the track-transaction cron job while Horizon has not
+#TRACK_TRANSACTION_MAX_RESCHEDULES=10
+
+# Maximum number of reconcile attempts for a pending transaction
+#MAX_RECONCILE_ATTEMPTS=5
+
+# Maximum age of a pending transaction in milliseconds
+#MAX_PENDING_TRANSACTION_AGE=30000
\ No newline at end of file
diff --git a/merged-packages/stellar-wallet-snap/.prettierignore b/merged-packages/stellar-wallet-snap/.prettierignore
new file mode 100644
index 00000000..a60030e3
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/.prettierignore
@@ -0,0 +1,2 @@
+dist/
+coverage/
diff --git a/merged-packages/stellar-wallet-snap/CHANGELOG.md b/merged-packages/stellar-wallet-snap/CHANGELOG.md
new file mode 100644
index 00000000..a5cdea1f
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+[Unreleased]: https://github.com/MetaMask/snap-stellar-wallet/
diff --git a/merged-packages/stellar-wallet-snap/LICENSE.APACHE2 b/merged-packages/stellar-wallet-snap/LICENSE.APACHE2
new file mode 100644
index 00000000..49966a71
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/LICENSE.APACHE2
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2022 ConsenSys Software Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/merged-packages/stellar-wallet-snap/LICENSE.MIT0 b/merged-packages/stellar-wallet-snap/LICENSE.MIT0
new file mode 100644
index 00000000..74e1d3df
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/LICENSE.MIT0
@@ -0,0 +1,16 @@
+MIT No Attribution
+
+Copyright 2022 ConsenSys Software Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this
+software and associated documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/merged-packages/stellar-wallet-snap/README.md b/merged-packages/stellar-wallet-snap/README.md
new file mode 100644
index 00000000..ab6ed963
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/README.md
@@ -0,0 +1,69 @@
+# Stellar Wallet Snap
+
+MetaMask Snap that implements Stellar account management, signing, sends, trustlines, and related multichain client APIs.
+
+## Configuration
+
+Rename `.env.example` to `.env`. Runtime settings are loaded from `.env`.
+
+## Folder structure
+
+High-level layout of `packages/snap` (nested implementation folders like `services/transaction/simulation` are omitted):
+
+| Folder | Purpose |
+| -------------------------------- | ------------------------------------------------------------------------------ |
+| `locales/` | Localized UI strings (built from `messages.json`) |
+| `images/` | Snap icons / static assets |
+| `scripts/` | Build helpers (manifest, locale, preinstalled snap) |
+| `docs/` | Package documentation (folder map, use cases, transaction pipeline) |
+| `docs/use-cases/` | Use-case docs grouped by domain (`keyring`, `client-request`, `cron-job`, ...) |
+| `docs/misc/transaction/` | Transaction build / validate / submit reference |
+| `docs/misc/synchronization/` | Background sync flow and component details |
+| `src/api/` | Shared types and Superstruct validators (CAIP, XDR, amounts, …) |
+| `src/handlers/` | Snap RPC / lifecycle entry handlers; orchestrates use cases |
+| `src/handlers/asset/` | `onAssets*` lookups, conversion, market data, historical price |
+| `src/handlers/clientRequest/` | SIP-31 client methods (send, trustline, fees, …) |
+| `src/handlers/cronjob/` | Background sync, transaction tracking, confirmation refresh |
+| `src/handlers/keyring/` | Keyring API (accounts, sign tx / message / auth entry) |
+| `src/handlers/user-input/` | Confirmation dialog button / form events |
+| `src/services/` | Domain logic (no Snap entry-point routing) |
+| `src/services/account/` | Keyring account persistence and lookups |
+| `src/services/asset-metadata/` | Asset metadata resolution (symbol, icon, …) |
+| `src/services/cache/` | In-memory and state-backed caches |
+| `src/services/network/` | Horizon / network calls, fees, activation checks |
+| `src/services/on-chain-account/` | On-chain balances, trustlines, and sync |
+| `src/services/price/` | Spot / conversion prices |
+| `src/services/state/` | Snap state manager |
+| `src/services/sync/` | Assets / Balances / Transactions synchronization orchestration |
+| `src/services/transaction/` | Build, validate, map, send, and pending keyring transactions |
+| `src/services/transaction-scan/` | Security / Blockaid scanning |
+| `src/services/wallet/` | HD wallet and signing |
+| `src/ui/` | Snap UI (JSX screens shown inside MetaMask) |
+| `src/ui/confirmation/` | Confirmation dialogs and UX controller |
+| `src/ui/images/` | Inline UI assets |
+| `src/utils/` | Cross-cutting helpers (logging, i18n, CAIP, snap APIs, …) |
+
+## Use cases
+
+End-to-end flows (handler → services → UI) live under [`docs/use-cases/`](./docs/use-cases/).
+Background synchronization overview: [`docs/misc/synchronization/overview.md`](./docs/misc/synchronization/overview.md).
+Shared transaction build / validate / send: [`docs/misc/transaction/`](./docs/misc/transaction/README.md).
+
+## API examples
+
+### `keyring_createAccount`
+
+```typescript
+provider.request({
+ method: 'wallet_invokeKeyring',
+ params: {
+ snapId,
+ request: {
+ method: 'keyring_createAccount',
+ params: {
+ scope: 'stellar:pubnet', // CAIP-2 chain ID
+ },
+ },
+ },
+});
+```
diff --git a/merged-packages/stellar-wallet-snap/babel.config.js b/merged-packages/stellar-wallet-snap/babel.config.js
new file mode 100644
index 00000000..8165fe45
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ '@babel/preset-typescript',
+ ],
+};
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/synchronization/accounts.md b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/accounts.md
new file mode 100644
index 00000000..dbad7eff
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/accounts.md
@@ -0,0 +1,95 @@
+# Synchronization: accounts
+
+On-chain account snapshots (balances, trustlines, SEP-41 tokens) for **activated** keyring accounts.
+
+| | |
+| ---------------- | ---------------------------------------------------------------------------------------------------------------- |
+| **Service** | `[OnChainAccountSynchronizeService](../../../src/services/on-chain-account/OnChainAccountSynchronizeService.ts)` |
+| **Orchestrator** | `[SynchronizeService](../../../src/services/sync/SynchronizeService.ts)` |
+| **Snap state** | `[OnChainAccountRepository](../../../src/services/on-chain-account/OnChainAccountRepository.ts)` |
+| **Overview** | [synchronization.md](./synchronization.md) |
+
+## Participants
+
+| Component | Path | Role |
+| ---------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `SynchronizeService` | `services/sync` | Load activated pairs + SEP-41 catalog; run account + tx sync |
+| `OnChainAccountSynchronizeService` | `services/on-chain-account` | Merge snapshots, persist, emit |
+| `OnChainAccountService` | `services/on-chain-account` | Resolve live on-chain account |
+| `NetworkService` | `services/network` | [Horizon](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-an-account) account + SEP-41 balance reads |
+| `SyncAccountsHandler` | `handlers/cronjob` | Cron / scheduled entry |
+
+## Request / response
+
+Triggered via `SynchronizeService.synchronize` (see [syncAccounts.md](../../use-cases/cron-job/syncAccounts.md)). Emits keyring events after snap state is persisted:
+
+- `AccountBalancesUpdated`
+- `AccountAssetListUpdated`
+
+SEP-41 token balances are **not** from Horizon — they use Soroban RPC `balance(Address)` simulation.
+
+## Step-by-step
+
+1. `SynchronizeService` loads **activated** account pairs from [Horizon](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-an-account); unfunded / not-yet-activated accounts are skipped.
+2. Load SEP-41 asset metadata for the scope (shared with transaction sync on the same run).
+3. `OnChainAccountSynchronizeService.synchronize` — batch-fetch SEP-41 token balances (best effort).
+4. Load previous snap-state snapshots as merge baseline.
+5. Per activated account — apply SEP-41 balances, then **merge** persisted gaps (classic tombstones + SEP-41 backfill).
+6. **Compute deltas** — `#computeKeyringSyncDeltas` compares pre-sync snapshot vs merged on-chain view (visibility transitions → balance / asset-list payloads).
+7. Persist snapshots atomically, then emit `AccountBalancesUpdated` and `AccountAssetListUpdated`.
+
+Account sync and transaction sync run **in parallel** when both are enabled on the same `synchronize` call.
+
+## Tombstones, merge, and deltas
+
+Merge and deltas are two linked steps:
+
+1. `#mergePersistedEntriesIntoOnChainAccount` — fill gaps so the in-memory on-chain view is complete before diffing.
+2. `#computeKeyringSyncDeltas` — compare **persisted snapshot** vs **merged on-chain view** for visibility transitions:
+
+- newly visible → `added` (+ balance)
+- no longer visible → `removed` (+ balance `0`)
+- already not visible → omit (avoid flooding zeros)
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as SyncAccountsHandler
+ participant Sync as SynchronizeService
+ participant OnChain as OnChainAccountSynchronizeService
+ participant Network as NetworkService
+ participant State as OnChainAccountRepository
+ participant MM as MetaMask controller
+
+ Cron->>Sync: synchronize(accounts, scope)
+ Sync->>Network: resolve activated pairs (GET /accounts/:id)
+ Sync->>OnChain: synchronize(pairs, scope, sep41Assets)
+ OnChain->>Network: SEP-41 balance simulation
+ OnChain->>State: load last snapshots
+ loop per activated account
+ OnChain->>OnChain: apply SEP-41 balances
+ OnChain->>OnChain: merge (classic tombstones + SEP-41 backfill)
+ OnChain->>OnChain: computeKeyringSyncDeltas
+ end
+ OnChain->>State: saveMany
+ OnChain->>MM: AccountBalancesUpdated (delta balances)
+ OnChain->>MM: AccountAssetListUpdated (delta added/removed)
+```
+
+## Data source
+
+| Data | Source |
+| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Native + classic trustline balances | **Live on-chain** via [Horizon](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-an-account) `GET /accounts/:account_id` |
+| SEP-41 token balances | **Live on-chain** (Soroban simulation) |
+| Persisted snapshot | **Snap state** (`OnChainAccountRepository`) |
+| Keyring-facing balances / asset list | Emitted from latest snapshot (can be slightly stale until next sync) |
+
+## Related
+
+- [Horizon — Accounts](https://developers.stellar.org/docs/data/apis/horizon/api-reference/resources/accounts)
+- [Horizon — Retrieve an Account](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-an-account)
+- [syncAccounts.md](../../use-cases/cron-job/syncAccounts.md) — cron entry and params
+- [keyring.md](../../use-cases/keyring/keyring.md) — `listAccountAssets` / `getAccountBalances` read snap snapshots
+- [transaction.md](./transaction.md) — transaction sync on the same run
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/synchronization/assets.md b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/assets.md
new file mode 100644
index 00000000..88f2e4ee
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/assets.md
@@ -0,0 +1,65 @@
+# Synchronization: assets
+
+Asset metadata catalog refresh from the token API.
+
+| | |
+| ---------------- | -------------------------------------------------------------------------------------------------- |
+| **Service** | [`AssetMetadataService.synchronize`](../../../src/services/asset-metadata/AssetMetadataService.ts) |
+| **Orchestrator** | [`SynchronizeService.synchronizeAssets`](../../../src/services/sync/SynchronizeService.ts) |
+| **Snap state** | [`AssetMetadataRepository`](../../../src/services/asset-metadata/AssetMetadataRepository.ts) |
+| **Overview** | [synchronization.md](./synchronization.md) |
+
+## Participants
+
+| Component | Path | Role |
+| ---------------------- | ----------------------------------- | ----------------------------------- |
+| `SyncAssetsHandler` | `handlers/cronjob` | Cron entry |
+| `SynchronizeService` | `services/sync` | `synchronizeAssets(scope)` delegate |
+| `AssetMetadataService` | `services/asset-metadata` | Fetch + persist catalog |
+| `TokenApiClient` | `services/asset-metadata/token-api` | Token API |
+
+## Request / response
+
+Triggered by the `synchronizeAssets` cron (see [syncAssets.md](../../use-cases/cron-job/syncAssets.md)). Always uses **mainnet** scope — asset metadata is only available there, regardless of the user's selected network.
+
+Wire format: [SIP-29 Snap Assets API](https://metamask.github.io/SIPs/SIPS/sip-29) (lookup handlers read from the persisted catalog).
+
+## Step-by-step
+
+1. `SyncAssetsHandler` cron fires (always **mainnet** scope).
+2. `SynchronizeService.synchronizeAssets(scope)` delegates to `AssetMetadataService.synchronize`.
+3. Fetch full token list from the token API.
+4. Persist catalog via `AssetMetadataRepository.saveMany`.
+5. Failures are logged / tracked; the cron does not fail the whole Snap lifecycle.
+
+During account / transaction sync, `SynchronizeService` also preloads SEP-41 metadata via `fetchSep41AssetsOrSyncOnce` so transaction mapping and balance reads have catalog data without waiting for the assets cron.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as SyncAssetsHandler
+ participant Sync as SynchronizeService
+ participant Meta as AssetMetadataService
+ participant API as TokenApiClient
+ participant State as AssetMetadataRepository
+
+ Cron->>Sync: synchronizeAssets(mainnet)
+ Sync->>Meta: synchronize(mainnet)
+ Meta->>API: fetch token list
+ API-->>Meta: tokens metadata
+ Meta->>State: saveMany
+```
+
+## Data source
+
+| Data | Source |
+| ----------------------------------------- | ------------------------------------------------ |
+| Asset catalog (symbol, decimals, icon, …) | **Token API** → persisted in **snap state** |
+| On-demand lookup (`onAssetsLookup`) | Snap state catalog (fetch + persist missing ids) |
+
+## Related
+
+- [syncAssets.md](../../use-cases/cron-job/syncAssets.md) — cron entry
+- [assets.md](../../use-cases/assets/assets.md) — `onAssets*` handlers
+- [transaction.md](./transaction.md) — SEP-41 metadata used during tx mapping
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/synchronization/synchronization.md b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/synchronization.md
new file mode 100644
index 00000000..07fed364
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/synchronization.md
@@ -0,0 +1,88 @@
+# Synchronization
+
+How background sync wires accounts, transactions, and asset catalog together.
+
+| | |
+| ---------------- | -------------------------------------------------------------------------------- |
+| **Orchestrator** | [`SynchronizeService`](../../../src/services/sync/SynchronizeService.ts) |
+| **Accounts** | [accounts.md](./accounts.md) — on-chain snapshots (balances, trustlines, SEP-41) |
+| **Transactions** | [transaction.md](./transaction.md) — Horizon history, mapping, pending reconcile |
+| **Assets** | [assets.md](./assets.md) — token metadata catalog |
+
+## Participants
+
+| Component | Path | Role |
+| ------------------------- | ------------------ | ------------------------------------------------------ |
+| `CronjobHandler` | `handlers/cronjob` | Gate — skip if inactive / locked |
+| `SyncAccountsHandler` | `handlers/cronjob` | Cron / scheduled entry for accounts + txs |
+| `SyncAssetsHandler` | `handlers/cronjob` | Cron entry for asset catalog |
+| `TrackTransactionHandler` | `handlers/cronjob` | Poll until terminal → then synchronize |
+| `SynchronizeService` | `services/sync` | Hub — mutex, parallel account + tx sync, asset catalog |
+
+## Step-by-step
+
+### `synchronizeAccounts` run
+
+1. Load **activated** account pairs (skip unfunded).
+2. Preload SEP-41 metadata for the scope.
+3. Run **[accounts](./accounts.md)** and **[transactions](./transaction.md)** sync **in parallel**.
+4. Per-task failures are logged; they do not fail the whole run.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Sync as SynchronizeService
+ participant Assets as assets sync
+ participant Acc as accounts sync
+ participant Tx as transaction sync
+
+ Sync->>Assets: preload SEP-41 metadata
+ Assets-->>Sync: sep41Assets
+ par
+ Sync->>Acc: synchronize
+ and
+ Sync->>Tx: synchronize
+ end
+```
+
+Component detail: [accounts](./accounts.md) · [transactions](./transaction.md) · [assets](./assets.md)
+
+## Skip synchronization / delay synchronization
+
+`SynchronizeService` uses a mutex so only **one exclusive sync** runs at a time:
+
+| Overlapping request | Behavior |
+| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
+| Same accounts already in the current run | **Skip** (common during onboarding) |
+| Other accounts (e.g. after account switch) | **Delay** — schedule `synchronizeAccounts` background event (~2s) instead of waiting on the mutex |
+
+This avoids Snap request timeouts when syncs overlap.
+
+```mermaid
+sequenceDiagram
+ participant A as Sync request A
+ participant B as Sync request B
+ participant Sync as SynchronizeService
+ participant Cron as delayed synchronizeAccounts
+
+ A->>Sync: synchronize(accounts A)
+ Note over Sync: mutex held
+ B->>Sync: synchronize(accounts B)
+ alt B accounts already in A's run
+ Sync-->>B: skip
+ else B has other accounts
+ Sync->>Cron: schedule (~2s)
+ Note over Sync: mutex released later
+ Cron->>Sync: synchronize(accounts B)
+ end
+```
+
+## Related
+
+| Use case | Doc |
+| ------------------- | ------------------------------------------------------------------- |
+| Cron gate | [cronjob.md](../../use-cases/cron-job/cronjob.md) |
+| Sync accounts entry | [syncAccounts.md](../../use-cases/cron-job/syncAccounts.md) |
+| Sync assets entry | [syncAssets.md](../../use-cases/cron-job/syncAssets.md) |
+| Track submitted tx | [trackTransaction.md](../../use-cases/cron-job/trackTransaction.md) |
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/synchronization/transaction.md b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/transaction.md
new file mode 100644
index 00000000..45868f30
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/synchronization/transaction.md
@@ -0,0 +1,106 @@
+# Synchronization: transactions
+
+Maps [Horizon](https://developers.stellar.org/docs/data/apis/horizon/api-reference/resources/transactions) history to keyring transactions and reconciles snap-state pending txs.
+
+| | |
+| -------------- | ----------------------------------------------------------------------------------------------------- |
+| **Service** | [`TransactionSynchronizeService`](../../../src/services/transaction/TransactionSynchronizeService.ts) |
+| **Mapper** | [`TransactionMapper`](../../../src/services/transaction/TransactionMapper.ts) |
+| **Snap state** | [`TransactionRepository`](../../../src/services/transaction/TransactionRepository.ts) |
+| **Overview** | [synchronization.md](./synchronization.md) |
+
+## Participants
+
+| Component | Path | Role |
+| ------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `TransactionSynchronizeService` | `services/transaction` | Scan, map, reconcile, emit |
+| `TransactionMapper` | `services/transaction` | On-chain tx → keyring tx |
+| `KeyringTransactionBuilder` | `services/transaction` | Build keyring tx shapes |
+| `TransactionRepository` | `services/transaction` | Pending txs + scan cursors |
+| `NetworkService` | `services/network` | [Horizon](https://developers.stellar.org/docs/data/apis/horizon/api-reference/resources/transactions) fetch by account / hash |
+
+## Step-by-step
+
+1. **Create context** — load pending txs from snap state, last-scan cursors, all snap-managed accounts on scope (for SEP-41 receive), SEP-41 metadata map.
+2. **Scan** — paginated [account transactions](https://developers.stellar.org/docs/data/apis/horizon/api-reference/get-transactions-by-account-id) per **activated** account; map each tx; apply SEP-41 synthetic receive when eligible.
+3. **Reconcile pending** — for remaining pending hashes, [fetch by hash](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-a-transaction); map when found; increment reconcile attempt on 404.
+4. **Save & emit** — emit `AccountTransactionsUpdated`, then persist mapped txs + remaining pending + updated cursors.
+
+First scan for an account uses **DESC** (newest first). Incremental scans use **ASC** from the saved cursor.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Sync as TransactionSynchronizeService
+ participant Horizon as Horizon API
+ participant Mapper as TransactionMapper
+ participant State as TransactionRepository
+ participant MM as MetaMask controller
+
+ Sync->>State: load pending + cursors
+ loop per activated account
+ Sync->>Horizon: GET /accounts/:id/transactions
+ Horizon-->>Sync: on-chain txs
+ Sync->>Mapper: mapTransactionSafe
+ Mapper-->>Sync: keyring tx (or skip)
+ end
+ loop remaining pending
+ Sync->>Horizon: GET /transactions/:hash
+ alt found
+ Sync->>Mapper: map with pending state
+ else 404
+ Sync->>State: increment reconcileAttemptCount
+ end
+ end
+ Sync->>MM: AccountTransactionsUpdated
+ Sync->>State: save pending + cursors
+```
+
+## Transaction mapping
+
+### How we decide what type of activity it is
+
+| Activity type | When we map it as this | Extra mapping conditions |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Send | I am the sender, and the transaction only contains payment or create-account operations. | Send takes priority over swap. If there are multiple operations, we only show the first payment or create-account (recipient, asset, amount). |
+| Token send (SEP-41) | I am the sender, and it is a supported SEP-41 token we recognize. | Unsupported tokens may fall back to unknown. |
+| Swap | I am the sender, the transaction matches our swap pattern, and it includes a path payment that credits back to my own account. | Self-swap is not a receive. If there are multiple path payment operations, we only use the first one for from/to assets and amounts. |
+| Receive | At least one operation credits my account (payment, account creation, or swap). | Self-send / self-swap are not receives. Failed receives are hidden. Dust spam is hidden (very small incoming native XLM <= 0.001 from someone else). If multiple assets are credited, we show the first unique asset only (amounts are not summed). |
+| Token trust (add) | I am the sender, and every operation is adding trust for a token. | If there are multiple change-trust operations, we only show the first token. |
+| Token trust (remove) | I am the sender, and every operation is removing trust for a token. | If there are multiple change-trust operations, we only show the first token. |
+| Unknown | The transaction does not match any rule above, or mapping fails. | We still show it as activity rather than hiding it. |
+
+Notes:
+General rule for multi-operation transactions:
+
+one on-chain transaction = one history entry.
+
+We do not split multiple sends, swaps, or receives in the same transaction into separate rows.
+
+### Fee handling (Only for transactions that made from MetaMask)
+
+| Activity type | Fee while pending | When settled (confirmed / failed) |
+| -------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
+| Send | No fee shown yet. | We read the actual network fee from the settled transaction and show it in XLM. |
+| Token trust (add / remove) | No fee shown yet. | Same as send - actual network fee in XLM is shown after settlement. |
+| Swap | Estimated fee from the signed transaction. | Fee is replaced with the actual network fee from the settled transaction. |
+| Bridge send | Estimated fee from the signed transaction. | Fee is replaced with the actual network fee from the settled transaction. |
+
+### Swap amounts (Only for transactions that made from MetaMask)
+
+| Activity type | Amounts while pending | When settled (confirmed / failed) |
+| ----------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| Swap | Estimated amounts from the signed transaction, not the final executed amounts yet. | We re-read actual executed amounts from the on-chain result and update the from and to legs. |
+| Contract-based swap | Amounts shown as `0` - final amounts not known yet. | We try to re-map from on-chain data. If that fails, we keep the pending amounts (still `0`) as best effort. |
+| Cross-chain bridge send | No from/to amounts in the snap. | Handled from transaction history when available. |
+
+## Related
+
+- [Horizon — Transactions](https://developers.stellar.org/docs/data/apis/horizon/api-reference/resources/transactions)
+- [Horizon — Account's Transactions](https://developers.stellar.org/docs/data/apis/horizon/api-reference/get-transactions-by-account-id)
+- [Horizon — Retrieve a Transaction](https://developers.stellar.org/docs/data/apis/horizon/api-reference/retrieve-a-transaction)
+- [Horizon — Pagination](https://developers.stellar.org/docs/data/apis/horizon/api-reference/structure/pagination/page-arguments)
+- [syncAccounts.md](../../use-cases/cron-job/syncAccounts.md) — cron entry
+- [trackTransaction.md](../../use-cases/cron-job/trackTransaction.md) — post-submit poll
+- [keyring.md](../../use-cases/keyring/keyring.md) — `listAccountTransactions` = snap pending only
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-classic-trustline.md b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-classic-trustline.md
new file mode 100644
index 00000000..0bd1afed
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-classic-trustline.md
@@ -0,0 +1,61 @@
+# Send classic trustline asset
+
+Payment of a **classic issued asset** (CAIP-19 classic, non-native). Destination must already hold / be able to receive the asset — the Snap does **not** create the destination account for non-native assets.
+
+| | |
+| ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| **Service** | `TransactionService.createValidatedSendTransaction` → `#createValidatedClassicAssetTransfer` |
+| **Builder** | `TransactionBuilder.transfer` → `#send` (`Operation.payment`) |
+| **Client** | [`onAmountInput`](../../use-cases/client-request/onAmountInput.md), [`confirmSend`](../../use-cases/client-request/confirmSend.md) |
+| **Submit** | [Submit & bad-sequence retry](./submit-sequence-retry.md) |
+
+## Build
+
+1. Load destination account from the network.
+2. If destination is **not** activated → `AccountNotActivatedException` (classic non-native cannot use `createAccount`).
+3. Fetch base inclusion fee.
+4. `TransactionBuilder.transfer` with `isActivated: true`:
+ - Normalize amount to human-readable Stellar units (`normalizeAmount`).
+ - `#send` → single `Operation.payment` with `caip19ToStellarAsset(assetId)`.
+5. Source sequence / account id from `OnChainAccount`.
+
+Trustline existence / limits on sender and receiver are enforced in **validate**, not by adding a `changeTrust` op on this path (opt-in/out is [`changeTrustOpt`](../../use-cases/client-request/changeTrustOpt.md)).
+
+### Cache
+
+**Send / submit (`confirmSend`) does not use cache** (`useCache: false`). Destination load is always fresh so the envelope is safe to sign.
+
+**Preflight only (`onAmountInput`)** passes `useCache: true` when building the validated send for amount checks. See [onAmountInput cache note](../../use-cases/client-request/onAmountInput.md#note-cache-usage).
+
+## Validate
+
+Local checks against the sender (and destination when known):
+
+- Sender has enough of the classic asset to send.
+- Sender and destination trustlines allow the transfer (limit / authorization).
+- Sender can cover the network fee in XLM.
+
+## Send
+
+1. `Wallet.signTransaction`.
+2. `TransactionService.sendTransaction` — classic envelopes **can** use one automatic `txBadSeq` rebuild + re-sign when the tx source is this wallet account. See [submit-sequence-retry](./submit-sequence-retry.md).
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ participant Handler
+ participant Tx as TransactionService
+ participant Builder as TransactionBuilder
+ participant Sim as TransactionSimulator
+ participant Wallet
+
+ Handler->>Tx: createValidatedSendTransaction (classic)
+ Tx->>Tx: load destination (must be activated)
+ Tx->>Builder: transfer → payment
+ Builder-->>Tx: unsigned payment
+ Tx->>Sim: validate (balance, trustline, fee)
+ Note over Handler: confirmSend only ↓
+ Handler->>Wallet: signTransaction
+ Handler->>Tx: sendTransaction
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-native.md b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-native.md
new file mode 100644
index 00000000..f141343e
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-native.md
@@ -0,0 +1,78 @@
+# Send native XLM
+
+Native asset (slip44) send has two use cases, chosen by whether the destination is already funded on-chain.
+
+| | |
+| ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| **Service** | `TransactionService.createValidatedSendTransaction` → `#createValidatedClassicAssetTransfer` |
+| **Builder** | `TransactionBuilder.transfer` → `#send` or `#createAccount` |
+| **Client** | [`onAmountInput`](../../use-cases/client-request/onAmountInput.md), [`confirmSend`](../../use-cases/client-request/confirmSend.md) |
+| **Submit** | [Submit & bad-sequence retry](./submit-sequence-retry.md) |
+
+Only **native / slip44** can fund a new account. Classic / SEP-41 sends to an unactivated destination fail earlier (`AccountNotActivatedException` / `InvalidAssetForCreateAccountException`).
+
+## Cache
+
+**Send / submit (`confirmSend`) does not use cache** (`useCache: false`). Destination load is always fresh so the envelope is safe to sign.
+
+**Preflight only (`onAmountInput`)** passes `useCache: true` when building the validated send for amount checks. See [onAmountInput cache note](../../use-cases/client-request/onAmountInput.md#note-cache-usage).
+
+---
+
+## Use case A — destination is activated
+
+Send XLM to an account that already exists on the network.
+
+### Build
+
+1. Load destination → activated (`destinationAccount !== null`).
+2. Fetch base inclusion fee.
+3. `TransactionBuilder.transfer` → `#send` → single `Operation.payment` (native asset).
+4. Amount is normalized to human-readable Stellar units; sequence from sender `OnChainAccount`.
+
+### Validate
+
+Checks spendable native balance (after reserves) and that the sender can cover the payment plus network fee.
+
+### Send
+
+Sign → `sendTransaction` ([submit-sequence-retry](./submit-sequence-retry.md)).
+
+---
+
+## Use case B — destination is not activated
+
+Fund a new Stellar account by sending native XLM. The Snap builds a **`createAccount`** op instead of `payment`.
+
+### Build
+
+1. Load destination → not activated (`destinationAccount === null`).
+2. Fetch base inclusion fee.
+3. `TransactionBuilder.transfer` → `#createAccount` → `Operation.createAccount({ destination, startingBalance })`.
+4. **`startingBalance` = the send amount** (same value the user entered / confirmed). There is no separate “funding” field.
+
+### Validate
+
+- **`startingBalance` must be ≥ 1 XLM** (minimum when not sponsoring).
+- Sender must have enough spendable native to cover that starting balance plus fee.
+
+### Send
+
+Sign → `sendTransaction` ([submit-sequence-retry](./submit-sequence-retry.md)).
+
+---
+
+## Flow
+
+```mermaid
+flowchart TD
+ Start[Native send] --> Dest{Destination activated?}
+
+ Dest -->|yes — use case A| A1[Build: payment]
+ A1 --> A2[Validate: spendable XLM + fee]
+ A2 --> Sign[sign + send]
+
+ Dest -->|no — use case B| B1[Build: createAccount
startingBalance = send amount]
+ B1 --> B2[Validate: startingBalance ≥ 1 XLM
+ spendable cover]
+ B2 --> Sign
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-sep41.md b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-sep41.md
new file mode 100644
index 00000000..f00418f4
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-sep41.md
@@ -0,0 +1,64 @@
+# Send SEP-41
+
+Soroban contract token transfer (`SEP-41` CAIP-19 asset → `invokeHostFunction` calling `transfer`).
+
+| | |
+| ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| **Service** | `TransactionService.createValidatedSendTransaction` → `#createValidatedSep41Transfer` |
+| **Builder** | `TransactionBuilder.sep41Transfer` |
+| **Client** | [`onAmountInput`](../../use-cases/client-request/onAmountInput.md), [`confirmSend`](../../use-cases/client-request/confirmSend.md) |
+| **Submit** | [Submit & bad-sequence retry](./submit-sequence-retry.md) |
+
+## Build
+
+1. Destination must already be **activated**; otherwise `AccountNotActivatedException` (no `createAccount` for SEP-41).
+2. Fetch base inclusion fee.
+3. `TransactionBuilder.sep41Transfer`:
+ - Parse contract id from CAIP-19 asset reference.
+ - Build one `invokeHostFunction` op: `transfer(from, to, amount)` with amount in token **smallest units** (i128) — no classic decimal normalize.
+ - Source account + sequence come from the resolved `OnChainAccount`.
+4. If the asset row is missing from the on-chain snapshot (`getRawAsset`), fetch SEP-41 balances from the network and attach a local balance.
+5. Fail early if local balance < amount (`InsufficientBalanceException`).
+6. Network simulation attaches Soroban resource fee / footprint (`sorobanData`).
+
+### Cache
+
+**Send / submit paths (`confirmSend`) never use cache** (`useCache: false`). Destination load and SEP-41 simulation are always fresh so the envelope is safe to sign.
+
+**Preflight only (`onAmountInput`)** passes `useCache: true` so repeated amount checks stay responsive (SEP-41 sim keyed by asset + sender + recipient + scope, not amount). That result must not be signed. See [onAmountInput cache note](../../use-cases/client-request/onAmountInput.md#note-cache-usage).
+
+## Validate
+
+Local checks after network simulation has attached the Soroban resource fee:
+
+- Sender has enough of the SEP-41 token balance.
+- Sender can cover inclusion + resource fees in XLM.
+- Envelope is a single contract `transfer` invoke with a consistent source/sender.
+
+## Send
+
+1. `Wallet.signTransaction`.
+2. `TransactionService.sendTransaction` — see [submit-sequence-retry](./submit-sequence-retry.md).
+3. Sequence-only rebuild does **not** safely preserve Soroban `sorobanData`; on `txBadSeq` for invoke envelopes the caller should re-simulate / rebuild rather than relying on a blind sequence bump.
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ participant Handler
+ participant Tx as TransactionService
+ participant Builder as TransactionBuilder
+ participant Net as NetworkService
+ participant Sim as TransactionSimulator
+ participant Wallet
+
+ Handler->>Tx: createValidatedSendTransaction (SEP-41)
+ Tx->>Builder: sep41Transfer
+ Builder-->>Tx: unsigned invokeHostFunction
+ Tx->>Net: simulate (fresh for confirmSend)
+ Net-->>Tx: assembled envelope (+ resource fee)
+ Tx->>Sim: validate (balance + fees)
+ Note over Handler: confirmSend only ↓
+ Handler->>Wallet: signTransaction
+ Handler->>Tx: sendTransaction
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-swap.md b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-swap.md
new file mode 100644
index 00000000..b7eda37a
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/transaction/send-swap.md
@@ -0,0 +1,78 @@
+# Send swap / bridge (from XDR)
+
+Swap and bridge envelopes are **not** built by `TransactionBuilder` inside the Snap. MetaMask CrossChain API supplies Base64 XDR; the Snap decodes, checks the accepted op shape, validates, then (for submit) signs and sends.
+
+| | |
+| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Service** | `TransactionService.createValidatedSwapTransaction` |
+| **Decode** | `Transaction.fromXdr` |
+| **Shape gate** | `SwapTransactionXdrStruct` ([`api/xdr.ts`](../../../src/api/xdr.ts)) |
+| **Client** | [`computeFee`](../../use-cases/client-request/computeFee.md), [`signAndSendTransaction`](../../use-cases/client-request/signAndSendTransaction.md) |
+| **Submit** | [Submit & bad-sequence retry](./submit-sequence-retry.md) |
+
+## Accepted operation patterns from XDR
+
+`SwapTransactionXdrStruct` only checks **operation kind + order** (not balances / memos). Downstream service + simulator do the rest.
+
+| Ops (in order) | Meaning |
+| -------------------------------------- | ---------------------------------------------------------------------------------------- |
+| `[invokeHostFunction]` | Soroban swap (single contract invoke) |
+| `[payment]` | Bridge deposit (single payment to deposit account) **or** swap without a separate fee op |
+| `[pathPayment*]` | Classic swap without a trailing fee payment |
+| `[pathPayment*, payment]` | Classic swap + route fee payment |
+| `[changeTrust, pathPayment*]` | Trustline setup then swap (no fee op) |
+| `[payment, payment]` | Bridge: deposit payment + fee wallet payment |
+| `[changeTrust, pathPayment*, payment]` | Trustline + classic swap + fee payment |
+
+`pathPayment*` = `pathPaymentStrictSend` or `pathPaymentStrictReceive`.
+
+Anything else → rejected at the JSON-RPC struct (`Unsupported swap transaction operation shape`).
+
+## Build / decode
+
+1. Client passes the **same** XDR used for `computeFee` into `signAndSendTransaction`.
+2. `Transaction.fromXdr({ xdr, scope })`.
+3. `computingFee(transaction)`:
+ - If envelope has `invokeHostFunction` → `NetworkService.simulateTransaction` (fresh Soroban assemble / resource fee).
+ - Else → keep fee already on the envelope (Bridge / classic quote trusted).
+4. Preload participating accounts from the network (skipped for invoke-only envelopes).
+
+## Validate
+
+Local checks on the decoded envelope (balances, trustlines, fees) for whichever ops are present — e.g. path-payment amounts, trailing fee payment, leading change-trust, or Soroban invoke after simulation.
+
+## Send
+
+Only `signAndSendTransaction`:
+
+1. `Wallet.signTransaction` (user consent is **client-side** — Snap shows no confirmation).
+2. `sendTransaction` — see [submit-sequence-retry](./submit-sequence-retry.md).
+3. Persist pending keyring tx as `Swap` (same-chain) or `BridgeSend` (cross-chain) from `options.sourceAssetId` / `destAssetId`.
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Fee as ComputeFeeHandler
+ participant Sign as SignAndSendTransactionHandler
+ participant Tx as TransactionService
+ participant Net as NetworkService
+ participant Wallet
+
+ Client->>Fee: computeFee { xdr, scope }
+ Fee->>Tx: createValidatedSwapTransaction
+ Tx->>Tx: fromXdr + computingFee
+ opt Soroban invoke
+ Tx->>Net: simulateTransaction
+ end
+ Tx->>Tx: validate (balances / trustlines / fees)
+ Fee-->>Client: fee entries
+
+ Note over Client: User approves in client UI
+ Client->>Sign: signAndSendTransaction { same xdr, scope, options }
+ Sign->>Tx: createValidatedSwapTransaction
+ Sign->>Wallet: signTransaction
+ Sign->>Tx: sendTransaction
+ Sign-->>Client: { transactionId }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/misc/transaction/submit-sequence-retry.md b/merged-packages/stellar-wallet-snap/docs/misc/transaction/submit-sequence-retry.md
new file mode 100644
index 00000000..e2fb74c7
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/misc/transaction/submit-sequence-retry.md
@@ -0,0 +1,80 @@
+# Submit & bad-sequence retry (`txBadSeq`)
+
+Shared on-chain **submit** path used after any flow has built (or decoded), validated, and signed an envelope. This doc is only about submission and recovering when the account **sequence is wrong or too old**.
+
+Suggested name vs “general on-chain send”: prefer **submit / sequence retry** — it is not a payment-type chooser; it is the last mile of every successful submit.
+
+| | |
+| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Service** | `TransactionService.sendTransaction` |
+| **Network** | `NetworkService.send` |
+| **Rebuild** | `TransactionBuilder.rebuildTxnWithNewSeq` |
+| **Used by** | [`confirmSend`](../../use-cases/client-request/confirmSend.md), [`changeTrustOpt`](../../use-cases/client-request/changeTrustOpt.md), [`signAndSendTransaction`](../../use-cases/client-request/signAndSendTransaction.md), … |
+
+## Happy path
+
+```text
+assert scope matches envelope
+ → NetworkService.send(signed tx)
+ → optional poll for terminal SUCCESS
+ → return transaction hash
+```
+
+## When sequence is stale (`txBadSeq`)
+
+Stellar rejects the submit with `txBadSeq` when the envelope’s sequence does not match the source account’s current sequence (concurrent txs, race after a long confirmation dialog, etc.).
+
+`NetworkService.send` maps that RPC error to `TransactionRetryableException`.
+
+`TransactionService.sendTransaction` then:
+
+1. Checks the envelope **source** equals the resolved `onChainAccount.accountId` (this wallet consumes the sequence).
+ - If source is **another** account → rethrow (cannot bump someone else’s sequence).
+2. Reloads the account from the network (`getAccount`) for a fresh `sequenceNumber`.
+3. `rebuildTxnWithNewSeq` — clone ops / fee / timebounds onto a new envelope with the new sequence.
+4. `wallet.signTransaction` again.
+5. Submit **once** more.
+
+Only **one** automatic retry.
+
+## Soroban / `invokeHostFunction` caveat
+
+Sequence-only rebuild copies classic-style fields and operations; it does **not** correctly preserve assembled Soroban `sorobanData`. JSDoc on `sendTransaction` states that for invoke envelopes, `txBadSeq` should not be treated as a safe auto-retry — the caller should **re-simulate / re-assemble** (e.g. fresh quote or fresh SEP-41 sim) instead of relying on a blind sequence bump.
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ participant Handler
+ participant Tx as TransactionService
+ participant Net as NetworkService
+ participant Builder as TransactionBuilder
+ participant Wallet
+
+ Handler->>Wallet: signTransaction (initial)
+ Handler->>Tx: sendTransaction
+ Tx->>Net: send
+ alt SUCCESS
+ Net-->>Tx: hash
+ Tx-->>Handler: transactionId
+ else txBadSeq (retryable)
+ Net-->>Tx: TransactionRetryableException
+ alt source === wallet account
+ Tx->>Net: getAccount (fresh sequence)
+ Tx->>Builder: rebuildTxnWithNewSeq
+ Tx->>Wallet: signTransaction
+ Tx->>Net: send (retry once)
+ Net-->>Tx: hash
+ Tx-->>Handler: transactionId
+ else source is another account
+ Tx-->>Handler: rethrow
+ end
+ end
+```
+
+## After submit (callers)
+
+Handlers typically then:
+
+- `savePendingKeyringTransactionSafe`
+- `TrackTransactionHandler.scheduleBackgroundEvent`
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/README.md b/merged-packages/stellar-wallet-snap/docs/use-cases/README.md
new file mode 100644
index 00000000..ad094cda
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/README.md
@@ -0,0 +1,60 @@
+# Use cases
+
+High-level flows for the Stellar Wallet Snap. Each doc focuses on **handlers**, **services**, and **UI** involved in a use case — utility modules are omitted.
+
+## Client request (`onClientRequest`)
+
+| Use case | Entry method | Doc |
+| ----------------------------------- | ------------------------ | ----------------------------------------------------------------------- |
+| Validate send destination | `onAddressInput` | [onAddressInput.md](./client-request/onAddressInput.md) |
+| Preflight send amount | `onAmountInput` | [onAmountInput.md](./client-request/onAmountInput.md) |
+| Confirm & submit send | `confirmSend` | [confirmSend.md](./client-request/confirmSend.md) |
+| Quote swap / bridge fee | `computeFee` | [computeFee.md](./client-request/computeFee.md) |
+| Sign & submit swap / bridge | `signAndSendTransaction` | [signAndSendTransaction.md](./client-request/signAndSendTransaction.md) |
+| Change trustline (opt-in / opt-out) | `changeTrustOpt` | [changeTrustOpt.md](./client-request/changeTrustOpt.md) |
+
+## Cronjob (`onCronjob`)
+
+All methods are no-ops when MetaMask is inactive or the wallet is **locked** — see [cronjob.md](./cron-job/cronjob.md).
+
+| Use case | Entry method | Doc |
+| --------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| Cron gate (locked / inactive) | `CronjobHandler` | [cronjob.md](./cron-job/cronjob.md) |
+| Sync overview (accounts + txs + assets) | `SynchronizeService` | [synchronization.md](../misc/synchronization/synchronization.md) |
+| Track submitted transaction | `trackTransaction` | [trackTransaction.md](./cron-job/trackTransaction.md) · [transaction sync](./../misc/synchronization/transaction.md) |
+| Sync selected / listed accounts | `synchronizeAccounts` | [syncAccounts.md](./cron-job/syncAccounts.md) · [accounts sync](./../misc/synchronization/accounts.md) |
+| Sync asset metadata catalog | `synchronizeAssets` | [syncAssets.md](./cron-job/syncAssets.md) · [assets sync](./../misc/synchronization/assets.md) |
+| Refresh open confirmation | `refreshConfirmationContext` | [refreshConfirmationContext.md](./cron-job/refreshConfirmationContext.md) |
+
+## Keyring (`onKeyringRequest`)
+
+Account management is summarized in one place; SEP-43 signing methods have their own docs.
+
+| Use case | Entry method | Doc |
+| ---------------------------------------- | ----------------- | -------------------------------------------------- |
+| Keyring overview (accounts, balances, …) | `KeyringHandler` | [keyring.md](./keyring/keyring.md) |
+| Sign transaction (no broadcast) | `signTransaction` | [signTransaction.md](./keyring/signTransaction.md) |
+| Sign message | `signMessage` | [signMessage.md](./keyring/signMessage.md) |
+| Sign Soroban auth entry | `signAuthEntry` | [signAuthEntry.md](./keyring/signAuthEntry.md) |
+
+## Assets (`endowment:assets`)
+
+| Use case | Entry method | Doc |
+| ----------------------------------- | --------------- | ------------------------------- |
+| Assets overview (lookup, prices, …) | `AssetsHandler` | [assets.md](./assets/assets.md) |
+
+## User input (`onUserInput`)
+
+| Use case | Entry method | Doc |
+| ---------------------------- | ------------------ | ----------------------------------------- |
+| Interactive UI event routing | `UserInputHandler` | [userInput.md](./user-input/userInput.md) |
+
+## Synchronization
+
+Background sync flow and components: [synchronization.md](../misc/synchronization/synchronization.md).
+
+| Component | Doc |
+| -------------------------------- | -------------------------------------------------------- |
+| Accounts (balances / trustlines) | [accounts.md](../misc/synchronization/accounts.md) |
+| Transactions (history / pending) | [transaction.md](../misc/synchronization/transaction.md) |
+| Asset catalog | [assets.md](../misc/synchronization/assets.md) |
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/assets/assets.md b/merged-packages/stellar-wallet-snap/docs/use-cases/assets/assets.md
new file mode 100644
index 00000000..1ba14470
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/assets/assets.md
@@ -0,0 +1,33 @@
+# Use case: assets (`AssetsHandler`)
+
+Asset metadata and price entry points via Snap Assets API handlers → `AssetsHandler`.
+
+| | |
+| ---------- | ----------------------------------------------------------------------------------------------------------- |
+| **Entry** | `onAssetsLookup` / `onAssetsConversion` / `onAssetsMarketData` / `onAssetHistoricalPrice` → `AssetsHandler` |
+| **Source** | [`handlers/asset/assets.ts`](../../../src/handlers/asset/assets.ts) |
+
+## Participants
+
+| Component | Path | Role |
+| ---------------------- | ------------------------- | ------------------------------------------- |
+| `AssetsHandler` | `handlers/asset` | Assets API surface |
+| `AssetMetadataService` | `services/asset-metadata` | Resolve CAIP-19 asset metadata |
+| `PriceService` | `services/price` | Conversions, market data, historical prices |
+
+## Request / response
+
+Asset methods follow the MetaMask **Snap Assets API** ([SIP-29](https://metamask.github.io/SIPs/SIPS/sip-29)):
+
+- [Entry points](https://docs.metamask.io/snaps/reference/entry-points/) (`onAssetsLookup`, `onAssetsConversion`, `onAssetsMarketData`, `onAssetHistoricalPrice`)
+
+This Snap implements those handlers in `index.ts` and delegates to `AssetsHandler`. Lookup is restricted to Stellar asset ids.
+
+## Methods
+
+| Method | What it does | Data source |
+| ------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `onAssetsLookup` | Metadata for requested CAIP-19 asset ids | **Snap state** catalog via `AssetMetadataService` (fetch + persist when missing) |
+| `onAssetsConversion` | Conversion rates for requested asset pairs | **Price API** via `PriceService` (cached) |
+| `onAssetsMarketData` | Market data for requested assets | **Price API** via `PriceService` (cached) |
+| `onAssetHistoricalPrice` | Historical price intervals for a `from` → `to` pair | **Price API** via `PriceService` (cached) |
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md
new file mode 100644
index 00000000..f43277fe
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/changeTrustOpt.md
@@ -0,0 +1,100 @@
+# Use case: `changeTrustOpt`
+
+Add or remove a classic Stellar trustline for an asset on a managed account.
+
+| | |
+| ----------- | --------------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `ChangeTrustOptHandler` |
+| **Method** | `changeTrustOpt` (`ClientRequestMethod.ChangeTrustOpt`) |
+| **Actions** | `add` (opt-in) · `delete` (opt-out, limit forced to `0`) |
+| **Source** | [`handlers/clientRequest/changeTrustOpt.ts`](../../../src/handlers/clientRequest/changeTrustOpt.ts) |
+
+## Request / response (shape)
+
+**Request params**
+
+- `accountId` — keyring account UUID
+- `assetId` — CAIP-19 classic asset
+- `scope` — CAIP-2 chain ID
+- `action` — `"add"` `"delete"`
+- `limit` — optional; only for `"add"` (nonzero Stellar amount)
+
+**Response**
+
+- `{ status: true, transactionId }` — built, confirmed, signed, and submitted
+- `{ status: true }` — opt-in already satisfied (trustline exists with limit > 0), or became redundant while the dialog was open
+- `{ status: false }` — account not activated (funding prompt shown; not an RPC error)
+
+User rejection of the confirmation dialog throws `UserRejectedRequestError`.
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ----------------------------------- | --------------------------- | -------------------------------------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `changeTrustOpt` to the handler |
+| `ChangeTrustOptHandler` | `handlers/clientRequest` | Orchestrates the use case |
+| `AccountResolver` | `handlers/` | Loads keyring account + wallet + **live** on-chain account (network) |
+| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
+| `WalletService` / `Wallet` | `services/wallet` | Signing key material + `signTransaction` |
+| `OnChainAccountService` | `services/on-chain-account` | Fetch fresh on-chain balances / trustlines |
+| `AssetMetadataService` | `services/asset-metadata` | Resolve symbol / metadata for UI |
+| `TransactionService` | `services/transaction` | Build + validate change-trust tx; submit; save pending keyring tx |
+| `NetworkService` | `services/network` | Base fee (via `TransactionService`) |
+| `ConfirmationUXController` | `ui/confirmation` | Opt-in / opt-out confirmation dialog |
+| `TransactionScanService` | `services/transaction-scan` | Security scan while dialog is open |
+| `RefreshConfirmationContextHandler` | `handlers/cronjob` | Re-validate tx / fees while dialog is open |
+| `TrackTransactionHandler` | `handlers/cronjob` | Schedule background status tracking after submit |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `ChangeTrustOptHandler`.
+2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**. Unfunded accounts show the activation prompt and return `{ status: false }`.
+3. **Short-circuit** — If `add` and a trustline with limit > 0 already exists → `{ status: true }`. If `delete` and no trustline → `TrustlineNotFoundException`.
+4. **Build** — Resolve asset metadata; `TransactionService.createValidatedChangeTrustTransaction` builds a change-trust op (`delete` forces limit `"0"`).
+5. **Confirm** — `ConfirmationUXController` shows opt-in or opt-out UI (fee, security scan, local re-validation cron while open).
+6. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved; redundant opt-in returns `{ status: true }` without submit.
+7. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`.
+8. **Post-submit** — Persist pending keyring tx (`ChangeTrustOptIn` / `ChangeTrustOptOut`) and schedule `TrackTransactionHandler`.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as ChangeTrustOptHandler
+ participant Resolver as AccountResolver
+ participant Meta as AssetMetadataService
+ participant Tx as TransactionService
+ participant UI as ConfirmationUXController
+ participant Refresh as RefreshConfirmationContextHandler
+ participant Scan as TransactionScanService
+ participant Wallet
+ participant Track as TrackTransactionHandler
+
+ Client->>Handler: changeTrustOpt (add | delete)
+ Handler->>Resolver: resolve activated account (live on-chain)
+ Resolver-->>Handler: account, wallet, onChainAccount
+ Handler->>Handler: trustline needed?
+ Handler->>Meta: resolve(assetId)
+ Handler->>Tx: createValidatedChangeTrustTransaction
+ Tx-->>Handler: Transaction
+ Handler->>UI: renderConfirmationDialog
+ UI->>Refresh: schedule background refresh
+
+ loop While dialog open (until Client confirms / rejects)
+ Refresh->>Resolver: resolve on-chain account (live balances / trustlines)
+ Refresh->>Tx: rebuild & re-validate change-trust tx
+ Refresh->>Scan: security scan (refreshed envelope)
+ Refresh->>UI: update confirmation context
+ end
+
+ Client-->>UI: confirm
+ UI-->>Handler: confirmed
+ Handler->>Resolver: resolve again (live on-chain)
+ Handler->>Tx: createValidatedChangeTrustTransaction
+ Handler->>Wallet: signTransaction
+ Handler->>Tx: sendTransaction
+ Handler->>Tx: savePendingKeyringTransactionSafe
+ Handler->>Track: scheduleBackgroundEvent
+ Handler-->>Client: { status: true, transactionId }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/computeFee.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/computeFee.md
new file mode 100644
index 00000000..69a2ed9c
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/computeFee.md
@@ -0,0 +1,68 @@
+# Use case: `computeFee`
+
+Quotes fees for a swap / bridge envelope from MetaMask CrossChain API (same validation path as `signAndSendTransaction`).
+
+| | |
+| ------------------------ | ------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `ComputeFeeHandler` |
+| **Method** | `computeFee` (`ClientRequestMethod.ComputeFee`) |
+| **Source** | [`handlers/clientRequest/computeFee.ts`](../../../src/handlers/clientRequest/computeFee.ts) |
+| **Transaction pipeline** | [Swap / bridge from XDR](../../misc/transaction/send-swap.md) |
+
+## Client workflow
+
+1. After the user selects a quote, obtain the unsigned XDR from MetaMask CrossChain API.
+2. Call **`computeFee`** with that XDR and `scope` so the user can review fees.
+3. After approval in the client UI, call **`signAndSendTransaction`** with the **same** `transaction` XDR and `scope`.
+
+## Request / response (shape)
+
+**Request params**
+
+- `accountId` — keyring account UUID
+- `scope` — CAIP-2 chain ID
+- `transaction` — Base64-encoded swap / bridge XDR
+- `options` — optional (`visible`, `type`, `feeLimit`)
+
+**Response**
+
+- Array of fee entries: `[{ type: FeeType.Base, asset: { unit, type, amount, fungible } }]`
+- On insufficient native balance / fee coverage, still returns a fee entry using the **required** amount (so the client can surface the shortfall)
+
+No confirmation dialog; nothing is signed or submitted.
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ----------------------- | --------------------------- | -------------------------------------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `computeFee` to the handler |
+| `ComputeFeeHandler` | `handlers/clientRequest` | Orchestrates fee quoting |
+| `AccountResolver` | `handlers/` | Loads keyring account + wallet + **live** on-chain account (network) |
+| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
+| `OnChainAccountService` | `services/on-chain-account` | Balances for validation / simulation |
+| `TransactionService` | `services/transaction` | Decode, validate, simulate swap XDR; read `totalFee` |
+| `NetworkService` | `services/network` | Fees / simulation network reads (via `TransactionService`) |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `ComputeFeeHandler`.
+2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**.
+3. **Validate** — `TransactionService.createValidatedSwapTransaction` (same path as sign-and-send, including Soroban simulation when needed).
+4. **Return** — Base fee as display amount in native XLM; map balance/fee shortfalls to a required-amount fee entry.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as ComputeFeeHandler
+ participant Resolver as AccountResolver
+ participant Tx as TransactionService
+
+ Client->>Handler: computeFee { accountId, scope, transaction }
+ Handler->>Resolver: resolve activated account (live on-chain)
+ Resolver-->>Handler: account, wallet, onChainAccount
+ Handler->>Tx: createValidatedSwapTransaction
+ Tx-->>Handler: Transaction (totalFee)
+ Handler-->>Client: [{ type: Base, asset: XLM fee }]
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md
new file mode 100644
index 00000000..26a1010a
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md
@@ -0,0 +1,96 @@
+# Use case: `confirmSend`
+
+Confirms and submits a send for Unified Non-EVM Send (live on-chain data at build and submit time).
+
+| | |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `ConfirmSendHandler` |
+| **Method** | `confirmSend` (`ClientRequestMethod.ConfirmSend`) |
+| **Source** | [`handlers/clientRequest/confirmSend.ts`](../../../src/handlers/clientRequest/confirmSend.ts) |
+| **Transaction pipeline** | [SEP-41](../../misc/transaction/send-sep41.md) · [classic](../../misc/transaction/send-classic-trustline.md) · [native](../../misc/transaction/send-native.md) |
+
+## Request / response (shape)
+
+**Request params**
+
+- `fromAccountId` — keyring account UUID (coerced to `accountId` internally)
+- `toAddress` — Stellar destination
+- `assetId` — CAIP-19 classic / SEP-41 / slip44 (`scope` derived from `assetId`)
+- `amount` — human-readable amount string
+
+**Response**
+
+- `{ valid: true, errors: [], transactionId }` — confirmed, signed, and submitted
+- `{ valid: false, errors: [{ code }] }` — `Invalid` · `InsufficientBalance` · `InsufficientBalanceToCoverFee`
+
+User rejection of the confirmation dialog throws `UserRejectedRequestError`. Unactivated accounts return `{ valid: false, errors: [{ code: "Invalid" }] }` (no activation prompt).
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ----------------------------------- | --------------------------- | ---------------------------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `confirmSend` to the handler |
+| `ConfirmSendHandler` | `handlers/clientRequest` | Orchestrates the use case |
+| `AccountResolver` | `handlers/` | Loads keyring account + wallet + **live** on-chain account |
+| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
+| `WalletService` / `Wallet` | `services/wallet` | Signing key material + `signTransaction` |
+| `OnChainAccountService` | `services/on-chain-account` | Fresh balances / sequence |
+| `AssetMetadataService` | `services/asset-metadata` | Decimals, symbol, metadata for UI |
+| `TransactionService` | `services/transaction` | Build + validate send; submit; save pending keyring tx |
+| `NetworkService` | `services/network` | Base fee / network reads (via `TransactionService`) |
+| `ConfirmationUXController` | `ui/confirmation` | Send confirmation dialog |
+| `TransactionScanService` | `services/transaction-scan` | Security scan while dialog is open |
+| `RefreshConfirmationContextHandler` | `handlers/cronjob` | Refresh balances, tx, and scan while dialog is open |
+| `TrackTransactionHandler` | `handlers/cronjob` | Schedule background status tracking after submit |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `ConfirmSendHandler`.
+2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**.
+3. **Build** — Resolve asset metadata; convert amount; `TransactionService.createValidatedSendTransaction`.
+4. **Confirm** — `ConfirmationUXController` shows send UI (fee, estimated changes, security scan, local re-validation cron while open).
+5. **Refresh** — After confirm, account is resolved again from the live network; fee must not exceed what the user approved.
+6. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`.
+7. **Post-submit** — Persist pending keyring tx (`Send`) and schedule `TrackTransactionHandler` for sender + destination.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as ConfirmSendHandler
+ participant Resolver as AccountResolver
+ participant Meta as AssetMetadataService
+ participant Tx as TransactionService
+ participant UI as ConfirmationUXController
+ participant Refresh as RefreshConfirmationContextHandler
+ participant Scan as TransactionScanService
+ participant Wallet
+ participant Track as TrackTransactionHandler
+
+ Client->>Handler: confirmSend { fromAccountId, toAddress, assetId, amount }
+ Handler->>Resolver: resolve activated account (live on-chain)
+ Resolver-->>Handler: account, wallet, onChainAccount
+ Handler->>Meta: resolve(assetId)
+ Handler->>Tx: createValidatedSendTransaction
+ Tx-->>Handler: Transaction
+ Handler->>UI: renderConfirmationDialog
+ UI->>Refresh: schedule background refresh
+
+ loop While dialog open (until Client confirms / rejects)
+ Refresh->>Resolver: resolve on-chain account (live balances)
+ Refresh->>Tx: rebuild & re-validate send tx
+ Refresh->>Scan: security scan (refreshed envelope)
+ Refresh->>UI: update confirmation context
+ end
+
+ Client-->>UI: confirm
+ UI-->>Handler: confirmed
+ Handler->>Resolver: resolve again (live on-chain)
+ Handler->>Tx: createValidatedSendTransaction
+ Handler->>Wallet: signTransaction
+ Handler->>Tx: sendTransaction
+ Handler->>Tx: savePendingKeyringTransactionSafe
+ Handler->>Track: scheduleBackgroundEvent
+ Handler-->>Client: { valid: true, errors: [], transactionId }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAddressInput.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAddressInput.md
new file mode 100644
index 00000000..8b55cf95
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAddressInput.md
@@ -0,0 +1,47 @@
+# Use case: `onAddressInput`
+
+Validates a Stellar destination address for Unified Non-EVM Send (as the user types).
+
+| | |
+| ---------- | --------------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `OnAddressInputHandler` |
+| **Method** | `onAddressInput` (`ClientRequestMethod.OnAddressInput`) |
+| **Source** | [`handlers/clientRequest/onAddressInput.ts`](../../../src/handlers/clientRequest/onAddressInput.ts) |
+
+## Request / response (shape)
+
+**Request params**
+
+- `value` — Stellar address string to validate
+
+**Response**
+
+- `{ valid: true, errors: [] }` — address is a valid Stellar address
+- `{ valid: false, errors: [{ code: "Invalid" }] }` — validation failed
+
+No account resolution, confirmation UI, or network calls.
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ----------------------- | ------------------------ | -------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `onAddressInput` to the handler |
+| `OnAddressInputHandler` | `handlers/clientRequest` | Validates params via Superstruct |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `OnAddressInputHandler`.
+2. **Validate** — Request is checked against `OnAddressInputJsonRpcRequestStruct` (`value` must be a Stellar address).
+3. **Return** — `{ valid: true }` on success, or `{ valid: false, errors: [{ code: "Invalid" }] }` when params fail.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as OnAddressInputHandler
+
+ Client->>Handler: onAddressInput { value }
+ Handler->>Handler: validate Stellar address
+ Handler-->>Client: { valid: true, errors: [] }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md
new file mode 100644
index 00000000..eb3a757c
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/onAmountInput.md
@@ -0,0 +1,77 @@
+# Use case: `onAmountInput`
+
+Preflight-validates a send amount while the user types (balance and fee checks only; nothing is signed or submitted).
+
+| | |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `OnAmountInputHandler` |
+| **Method** | `onAmountInput` (`ClientRequestMethod.OnAmountInput`) |
+| **Source** | [`handlers/clientRequest/onAmountInput.ts`](../../../src/handlers/clientRequest/onAmountInput.ts) |
+| **Transaction pipeline** | [SEP-41](../../misc/transaction/send-sep41.md) · [classic](../../misc/transaction/send-classic-trustline.md) · [native](../../misc/transaction/send-native.md) |
+
+## Request / response (shape)
+
+**Request params**
+
+- `accountId` — keyring account UUID
+- `assetId` — CAIP-19 classic / SEP-41 / slip44 asset (`scope` is derived from `assetId`)
+- `value` — positive amount string (human-readable units)
+- `to` — optional Stellar destination; omitted → self-transfer validation
+
+**Response**
+
+- `{ valid: true, errors: [] }` — amount can fund a send (incl. fee)
+- `{ valid: false, errors: [{ code }] }` — `Invalid` · `InsufficientBalance` · `InsufficientBalanceToCoverFee`
+
+Unactivated accounts return `{ valid: false, errors: [{ code: "Invalid" }] }` (no activation prompt).
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ----------------------- | --------------------------- | --------------------------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `onAmountInput` to the handler |
+| `OnAmountInputHandler` | `handlers/clientRequest` | Orchestrates preflight validation |
+| `AccountResolver` | `handlers/` | Loads account + wallet + on-chain snapshot from **state** |
+| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
+| `OnChainAccountService` | `services/on-chain-account` | Persisted on-chain balances (via resolver) |
+| `AssetMetadataService` | `services/asset-metadata` | Decimals for amount conversion |
+| `TransactionService` | `services/transaction` | Build + validate send tx (`useCache: true`) |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `OnAmountInputHandler`.
+2. **Resolve** — `AccountResolver` loads keyring account, wallet, and on-chain account from snap state.
+3. **Convert** — Resolve asset metadata; convert `value` to smallest units; reject if sub-unit decimals remain.
+4. **Preflight** — `TransactionService.createValidatedSendTransaction` with cached network reads (destination defaults to sender).
+5. **Return** — Structured validation result; expected balance/fee failures are returned as error codes (not thrown).
+
+## Note: cache usage
+
+`onAmountInput` is the **only** client send path that passes `useCache: true`. Real send/submit (`confirmSend`) always uses `useCache: false` (fresh destination load + fresh SEP-41 simulation).
+
+For **SEP-41**, that preflight cache matters most:
+
+- Fee estimation needs an on-chain **simulation**. Without caching, every keystroke would hit RPC.
+- Simulation is reused and keyed by **asset, sender, recipient, and scope** — **not** by amount. Cached XDR may carry a **stale amount or sequence** — never sign it.
+- Balance is still checked **locally** before simulation, so insufficient funds fail fast.
+
+Classic / native preflight still uses `useCache: true` for destination-account / network reads so typing stays responsive; submit paths do not.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as OnAmountInputHandler
+ participant Resolver as AccountResolver
+ participant Meta as AssetMetadataService
+ participant Tx as TransactionService
+
+ Client->>Handler: onAmountInput { accountId, assetId, value, to? }
+ Handler->>Resolver: resolve from keyring state
+ Resolver-->>Handler: account, wallet, onChainAccount
+ Handler->>Meta: resolve(assetId)
+ Handler->>Tx: createValidatedSendTransaction (useCache)
+ Tx-->>Handler: ok
+ Handler-->>Client: { valid: true, errors: [] }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/signAndSendTransaction.md b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/signAndSendTransaction.md
new file mode 100644
index 00000000..e7abcd6e
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/client-request/signAndSendTransaction.md
@@ -0,0 +1,81 @@
+# Use case: `signAndSendTransaction`
+
+Signs and submits a swap / bridge envelope previously quoted via `computeFee`. **No Snap confirmation dialog** — the caller must obtain user consent first.
+
+| | |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
+| **Entry** | `onClientRequest` → `ClientRequestHandler` → `SignAndSendTransactionHandler` |
+| **Method** | `signAndSendTransaction` (`ClientRequestMethod.SignAndSendTransaction`) |
+| **Source** | [`handlers/clientRequest/signAndSendTransaction.ts`](../../../src/handlers/clientRequest/signAndSendTransaction.ts) |
+| **Transaction pipeline** | [Swap / bridge from XDR](../../misc/transaction/send-swap.md) |
+
+## Client workflow
+
+1. Quote fees with **`computeFee`** using the CrossChain API XDR.
+2. Obtain explicit user approval in the **client** UI.
+3. Call **`signAndSendTransaction`** with the **same** `transaction` XDR, `scope`, and swap asset options.
+
+## Request / response (shape)
+
+**Request params**
+
+- `accountId` — keyring account UUID
+- `scope` — CAIP-2 chain ID
+- `transaction` — Base64-encoded swap / bridge XDR (same as `computeFee`)
+- `options.sourceAssetId` — Stellar source asset (CAIP-19 / slip44)
+- `options.destAssetId` — destination asset (CAIP-19; may be another chain for bridges)
+- `options.visible` / `options.type` — optional
+
+**Response**
+
+- `{ transactionId }` — submitted transaction hash
+
+## Security note
+
+This handler does **not** show a Snap confirmation. The client is responsible for displaying details and obtaining approval before calling. Signing without caller-side consent is a critical vulnerability.
+
+## Participants
+
+| Component | Path | Role in this flow |
+| ------------------------------- | --------------------------- | -------------------------------------------------------------------- |
+| `ClientRequestHandler` | `handlers/clientRequest` | Routes `signAndSendTransaction` to the handler |
+| `SignAndSendTransactionHandler` | `handlers/clientRequest` | Orchestrates validate → sign → submit |
+| `AccountResolver` | `handlers/` | Loads keyring account + wallet + **live** on-chain account (network) |
+| `AccountService` | `services/account` | Keyring account lookup (via resolver) |
+| `WalletService` / `Wallet` | `services/wallet` | `signTransaction` |
+| `OnChainAccountService` | `services/on-chain-account` | Balances / sequence for validation |
+| `AssetMetadataService` | `services/asset-metadata` | Same-chain swap asset labels for pending tx |
+| `TransactionService` | `services/transaction` | Validate swap XDR; submit; save pending keyring tx |
+| `TrackTransactionHandler` | `handlers/cronjob` | Schedule background status tracking after submit |
+
+## Step-by-step
+
+1. **Route** — `onClientRequest` dispatches to `SignAndSendTransactionHandler`.
+2. **Resolve** — `AccountResolver` loads keyring account, wallet, and activated on-chain account from the **live network**.
+3. **Validate** — `TransactionService.createValidatedSwapTransaction` on the XDR.
+4. **Sign & send** — `Wallet.signTransaction` → `TransactionService.sendTransaction`.
+5. **Post-submit** — Persist pending keyring tx (`Swap` for same-chain, `BridgeSend` for cross-chain) and schedule `TrackTransactionHandler`.
+
+## Sequence (happy path)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Handler as SignAndSendTransactionHandler
+ participant Resolver as AccountResolver
+ participant Tx as TransactionService
+ participant Wallet
+ participant Track as TrackTransactionHandler
+
+ Note over Client: User already approved in client UI
+ Client->>Handler: signAndSendTransaction { accountId, scope, transaction, options }
+ Handler->>Resolver: resolve activated account (live on-chain)
+ Resolver-->>Handler: account, wallet, onChainAccount
+ Handler->>Tx: createValidatedSwapTransaction
+ Tx-->>Handler: Transaction
+ Handler->>Wallet: signTransaction
+ Handler->>Tx: sendTransaction
+ Handler->>Tx: savePendingKeyringTransactionSafe (Swap | BridgeSend)
+ Handler->>Track: scheduleBackgroundEvent
+ Handler-->>Client: { transactionId }
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/cronjob.md b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/cronjob.md
new file mode 100644
index 00000000..2cdf221c
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/cronjob.md
@@ -0,0 +1,44 @@
+# Use case: cronjob gate (`CronjobHandler`)
+
+All background events enter through `onCronjob` → `CronjobHandler`, which **skips work when MetaMask is inactive or the wallet is locked**.
+
+| | |
+| ---------- | ------------------------------------------------------------------------- |
+| **Entry** | `onCronjob` → `CronjobHandler` |
+| **Source** | [`handlers/cronjob/cronjob.ts`](../../../src/handlers/cronjob/cronjob.ts) |
+
+## Behavior
+
+1. Call `getClientStatus()` → `{ active, locked }`.
+2. If **`!active` or `locked`** → return immediately (no method dispatch, no side effects).
+3. Otherwise validate `request.method` against `BackgroundEventMethod` and route to the matching handler.
+
+This gate applies to **every** cron method (`trackTransaction`, `synchronizeAccounts`, `synchronizeAssets`, `refreshConfirmationContext`). Individual handlers do not re-check lock state.
+
+## Cronjob handlers
+
+| Cronjob handlers | Doc |
+| ---------------------------- | ---------------------------------------------------------------- |
+| `trackTransaction` | [trackTransaction.md](./trackTransaction.md) |
+| `synchronizeAccounts` | [syncAccounts.md](./syncAccounts.md) |
+| `synchronizeAssets` | [syncAssets.md](./syncAssets.md) |
+| `refreshConfirmationContext` | [refreshConfirmationContext.md](./refreshConfirmationContext.md) |
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Snap as onCronjob
+ participant Cron as CronjobHandler
+ participant Status as getClientStatus
+ participant Method as Cronjob handler
+
+ Snap->>Cron: background event request
+ Cron->>Status: active? locked?
+ alt not active or locked
+ Status-->>Cron: skip
+ Cron-->>Snap: return (no-op)
+ else unlocked and active
+ Cron->>Method: handle(request)
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/refreshConfirmationContext.md b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/refreshConfirmationContext.md
new file mode 100644
index 00000000..92a66ad3
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/refreshConfirmationContext.md
@@ -0,0 +1,86 @@
+# Use case: `refreshConfirmationContext`
+
+While a confirmation dialog is open, periodically refresh prices, security scan, and/or rebuild the pending transaction against live on-chain state.
+
+| | |
+| ---------- | ----------------------------------------------------------------------------------------------------------- |
+| **Entry** | `onCronjob` → `CronjobHandler` → `RefreshConfirmationContextHandler` |
+| **Method** | `refreshConfirmationContext` (`BackgroundEventMethod.RefreshConfirmationContext`) |
+| **Source** | [`handlers/cronjob/refreshConfirmationContext/`](../../../src/handlers/cronjob/refreshConfirmationContext/) |
+| **Gate** | Skipped when wallet locked / inactive — see [cronjob.md](./cronjob.md) |
+
+Scheduled by `ConfirmationUXController` when a dialog opens with pricing, security scanning, and/or local simulation enabled (e.g. [`confirmSend`](../client-request/confirmSend.md), [`changeTrustOpt`](../client-request/changeTrustOpt.md)).
+
+## Request params
+
+- `interfaceId` — Snap UI interface id
+- `interfaceKey` — which confirmation view
+- `scope` — CAIP-2 chain ID
+- `refresherKeys` — which slices to run this cycle: `Prices` · `Scan` · `Transaction`
+
+## Participants
+
+| Component | Path | Role |
+| ----------------------------------- | ------------------------------------------- | ----------------------------------------------------- |
+| `RefreshConfirmationContextHandler` | `handlers/cronjob` | Orchestrate refreshers, re-render, reschedule |
+| `ConfirmationPriceRefresher` | `handlers/cronjob/.../priceRefresher` | Spot prices via `PriceService` |
+| `ConfirmationScanRefresher` | `handlers/cronjob/.../scanRefresher` | Blockaid / security scan via `TransactionScanService` |
+| `ConfirmationTransactionRefresher` | `handlers/cronjob/.../transactionRefresher` | Rebuild + re-validate pending tx (live account) |
+| `ConfirmationUXController` | `ui/confirmation` | Apply patched context to the open dialog |
+
+## Refreshers
+
+### Prices
+
+Fetches / updates token spot prices shown on the confirmation (fee asset, send asset, etc.). Requests reschedule while pricing is still needed and not in a terminal error state.
+
+### Security scan
+
+Runs (or refreshes) the remote security scan on the current transaction envelope in context. Uses the **latest** envelope when the transaction refresher has already patched it this cycle.
+
+### Transaction rebuild
+
+Runs **first** when enabled:
+
+1. Resolve live on-chain account.
+2. Rebuild the pending send / change-trust envelope (fresh fee, sequence, time bounds).
+3. Re-validate locally; update fee / validation status in context.
+4. Write the rebuilt XDR into the security-scan request so the scan refresher does not scan a stale snapshot.
+
+## Step-by-step (one cycle)
+
+1. Resolve enabled refreshers from `refresherKeys`.
+2. Load interface context; if the dialog was dismissed → stop (no reschedule).
+3. Run **transaction** refresher alone (if selected), merge its patch.
+4. Run **prices** and **scan** in parallel on the updated context.
+5. Merge patches → `ConfirmationUXController.updateConfirmation`.
+6. If any refresher asks to **reschedule** → schedule the next `refreshConfirmationContext` event.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as RefreshConfirmationContextHandler
+ participant TxR as Transaction refresher
+ participant Price as Price refresher
+ participant Scan as Scan refresher
+ participant UI as ConfirmationUXController
+
+ Cron->>Cron: load interface context
+ alt dialog gone
+ Cron-->>Cron: stop
+ else still open
+ opt Transaction in refresherKeys
+ Cron->>TxR: rebuild + validate (live)
+ TxR-->>Cron: patch (xdr, fee, status)
+ end
+ par
+ Cron->>Price: refresh spot prices
+ Cron->>Scan: security scan (latest xdr)
+ end
+ Cron->>UI: updateConfirmation
+ opt any reschedule
+ Cron->>Cron: schedule next cycle
+ end
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAccounts.md b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAccounts.md
new file mode 100644
index 00000000..93d3aa87
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAccounts.md
@@ -0,0 +1,47 @@
+# Use case: `synchronizeAccounts`
+
+Periodically (or on demand) refresh on-chain account state and transaction history for keyring accounts.
+
+| | |
+| ------------------- | ----------------------------------------------------------------------------------- |
+| **Entry** | `onCronjob` → `CronjobHandler` → `SyncAccountsHandler` |
+| **Method** | `synchronizeAccounts` (`BackgroundEventMethod.SynchronizeAccounts`) |
+| **Source** | [`handlers/cronjob/syncAccounts.ts`](../../../src/handlers/cronjob/syncAccounts.ts) |
+| **Synchronization** | [synchronization](../../misc/synchronization/synchronization.md) |
+| **Gate** | Skipped when wallet locked / inactive — see [cronjob.md](./cronjob.md) |
+
+Also schedulable via `SyncAccountsHandler.scheduleBackgroundEvent` (e.g. after account changes). Declarative cron may omit params → treat as **selected** accounts.
+
+## Request params
+
+- `accountIds` — optional
+ - omitted / `'selected'` → all **selected** keyring accounts
+ - UUID array → those account ids only
+
+Scope comes from `AppConfig.selectedNetwork`.
+
+## Participants
+
+| Component | Path | Role |
+| --------------------- | ------------------ | ---------------------------------------------------------- |
+| `SyncAccountsHandler` | `handlers/cronjob` | Resolve which accounts to sync |
+| `AccountService` | `services/account` | `getAllSelected` / `findByIds` |
+| `SynchronizeService` | `services/sync` | Sync balances, trustlines, transactions for those accounts |
+
+## Step-by-step
+
+1. Resolve account list (`selected` vs explicit ids).
+2. `SynchronizeService.synchronize(accounts, { scope })` — updates on-chain snapshots and related history for the configured network.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as SyncAccountsHandler
+ participant Acc as AccountService
+ participant Sync as SynchronizeService
+
+ Cron->>Acc: getAllSelected() or findByIds(...)
+ Acc-->>Cron: accounts
+ Cron->>Sync: synchronize(accounts, selectedNetwork)
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAssets.md b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAssets.md
new file mode 100644
index 00000000..9f6be3b6
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/syncAssets.md
@@ -0,0 +1,40 @@
+# Use case: `synchronizeAssets`
+
+Refresh the Snap’s asset metadata catalog from the token API (mainnet only).
+
+| | |
+| ------------------- | ------------------------------------------------------------------------------- |
+| **Entry** | `onCronjob` → `CronjobHandler` → `SyncAssetsHandler` |
+| **Method** | `synchronizeAssets` (`BackgroundEventMethod.SynchronizeAssets`) |
+| **Source** | [`handlers/cronjob/syncAssets.ts`](../../../src/handlers/cronjob/syncAssets.ts) |
+| **Synchronization** | [synchronization](../../misc/synchronization/synchronization.md) |
+| **Gate** | Skipped when wallet locked / inactive — see [cronjob.md](./cronjob.md) |
+
+Declarative cron in `snap.manifest.json` (no params required).
+
+## Participants
+
+| Component | Path | Role |
+| ---------------------- | ------------------------- | ---------------------------- |
+| `SyncAssetsHandler` | `handlers/cronjob` | Cron entry |
+| `SynchronizeService` | `services/sync` | `synchronizeAssets(scope)` |
+| `AssetMetadataService` | `services/asset-metadata` | Pull / persist asset catalog |
+
+## Step-by-step
+
+1. Always use **mainnet** scope (`KnownCaip2ChainId.Mainnet`) — asset metadata is only available there, regardless of the user’s selected network.
+2. `SynchronizeService.synchronizeAssets` → `AssetMetadataService.synchronize`.
+3. Failures are logged / tracked; the cron does not throw through to fail the whole Snap lifecycle aggressively.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as SyncAssetsHandler
+ participant Sync as SynchronizeService
+ participant Meta as AssetMetadataService
+
+ Cron->>Sync: synchronizeAssets(mainnet)
+ Sync->>Meta: synchronize(mainnet)
+ Meta-->>Sync: catalog updated
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/trackTransaction.md b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/trackTransaction.md
new file mode 100644
index 00000000..2706bb10
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/cron-job/trackTransaction.md
@@ -0,0 +1,72 @@
+# Use case: `trackTransaction`
+
+After a tx is submitted, keep polling Horizon until it shows up, then refresh the affected accounts’ balances / history.
+
+| | |
+| ------------------- | ------------------------------------------------------------------------------------------- |
+| **Entry** | `onCronjob` → `CronjobHandler` → `TrackTransactionHandler` |
+| **Method** | `trackTransaction` (`BackgroundEventMethod.TrackTransaction`) |
+| **Source** | [`handlers/cronjob/trackTransaction.ts`](../../../src/handlers/cronjob/trackTransaction.ts) |
+| **Synchronization** | [synchronization](../../misc/synchronization/synchronization.md) |
+| **Gate** | Skipped when wallet locked / inactive — see [cronjob.md](./cronjob.md) |
+
+Scheduled right after submit by [`confirmSend`](../client-request/confirmSend.md), [`changeTrustOpt`](../client-request/changeTrustOpt.md), [`signAndSendTransaction`](../client-request/signAndSendTransaction.md).
+
+## Request params
+
+- `txId` — Stellar transaction hash
+- `scope` — CAIP-2 chain ID
+- `accountIdsOrAddresses` — `[senderAccountUuid]` or `[senderAccountUuid, receiverAddress]`
+- `attempt` — optional reschedule counter (default `0`)
+
+## What Horizon returns
+
+| Outcome | Meaning |
+| ------------------- | ------------------------------------------------- |
+| **404 / not found** | Not indexed yet (or unknown hash) |
+| **Found + success** | On-chain succeeded → keyring status **Confirmed** |
+| **Found + fail** | On-chain failed → keyring status **Failed** |
+
+Both found outcomes are **terminal** — settlement is done; sync accounts.
+
+## Participants
+
+| Component | Path | Role |
+| ------------------------- | ------------------ | --------------------------------------------------- |
+| `TrackTransactionHandler` | `handlers/cronjob` | Poll Horizon, reschedule, sync on settle |
+| `NetworkService` | `services/network` | `getTransaction` from Horizon |
+| `AccountService` | `services/account` | Resolve sender / optional receiver keyring accounts |
+| `SynchronizeService` | `services/sync` | Refresh balances / history after settle |
+
+## Step-by-step
+
+1. Ask Horizon for `txId`.
+2. **404 / not found** or **network error**:
+ - If `attempt < trackTransactionMaxReschedules` → reschedule another poll (~2s, `attempt++`).
+ - If **max attempts reached** → **skip** (stop tracking this tx; no further reschedule). Periodic `synchronizeAccounts` may still pick it up later.
+3. **Found (confirmed or failed)** → load sender (and receiver if it is in the keyring) → `SynchronizeService.synchronize`.
+4. Any other unexpected error → log / track; skip further tracking for this run.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Cron as TrackTransactionHandler
+ participant Net as NetworkService
+ participant Acc as AccountService
+ participant Sync as SynchronizeService
+
+ Cron->>Net: getTransaction(txId, scope)
+ alt 404 / network error
+ Net-->>Cron: not found / network error
+ alt attempt < max
+ Cron->>Cron: reschedule poll (attempt++)
+ else attempt >= max
+ Cron->>Cron: Skip — stop tracking
+ end
+ else completed
+ Net-->>Cron: transaction
+ Cron->>Acc: find sender (+ optional receiver)
+ Cron->>Sync: synchronize(accounts)
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md
new file mode 100644
index 00000000..a2bbd1ab
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md
@@ -0,0 +1,50 @@
+# Use case: keyring (`KeyringHandler`)
+
+Account management and SEP-43 signing entry points via `onKeyringRequest` → `KeyringHandler`.
+
+| | |
+| ---------- | ------------------------------------------------------------------------- |
+| **Entry** | `onKeyringRequest` → `KeyringHandler` |
+| **Source** | [`handlers/keyring/keyring.ts`](../../../src/handlers/keyring/keyring.ts) |
+
+## Participants
+
+| Component | Path | Role |
+| ----------------------- | --------------------------- | ----------------------------------------------------------------------- |
+| `KeyringHandler` | `handlers/keyring` | Keyring API surface + routing |
+| `AccountService` | `services/account` | Persist / derive / select accounts (snap state) |
+| `OnChainAccountService` | `services/on-chain-account` | Snap-state snapshots for balances/assets; live activation for discovery |
+| `TransactionService` | `services/transaction` | Local pending keyring txs for `listAccountTransactions` |
+| `SyncAccountsHandler` | `handlers/cronjob` | Scheduled after selection changes to refresh on-chain snapshots |
+
+## Request / response
+
+Account-management methods follow the MetaMask **Keyring API** (request method names `keyring_*`, params, and return types):
+
+- [Account Management API](https://docs.metamask.io/snaps/reference/keyring-api/account-management/)
+- [`@metamask/keyring-api` docs](https://metamask.github.io/keyring-api/latest/)
+
+This Snap implements the `Keyring` interface; calls arrive via `wallet_invokeKeyring` / `onKeyringRequest` and are dispatched with `handleKeyringRequest`.
+
+`submitRequest` uses **SEP-43** request/response shapes — see [SEP-43](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md).
+
+## Methods
+
+Requests are origin-checked, then dispatched to the methods below.
+
+| Method | What it does | Data source |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
+| `listAccounts` | List all Stellar keyring accounts | **Snap state** (`AccountService`) |
+| `getAccount` | Get one account by id | **Snap state** |
+| `createAccount` | Derive / create one account; emit `AccountCreated` for new accounts (rollback delete if emit fails) | **Snap state** (HD derive + persist) |
+| `createAccounts` | Batch BIP-44 create (single index or range); Snap keyring v2 path — no `AccountCreated` events | **Snap state** (HD derive + persist) |
+| `deleteAccount` | Emit `AccountDeleted`, then remove the account | **Snap state** |
+| `setSelectedAccounts` | Validate ids exist, then schedule `synchronizeAccounts` for those accounts | |
+| `listAccountAssets` | Visible CAIP asset ids; if no snapshot yet, returns native slip44 only | **Snap state** on-chain snapshot (background-synced; not a live Horizon read) |
+| `getAccountBalances` | Balances for requested assets; missing / inactive snapshot → native `0` if asked | **Snap state** on-chain snapshot (same as above; can be slightly stale within the sync window) |
+| `listAccountTransactions` | Paginated keyring transactions for the account | **Snap state** (pending / local txs via `TransactionService` — **not** Horizon history) |
+| `discoverAccounts` | Derive BIP-44 address for index; return it only if activated on any requested scope | Derive locally; activation check is **live on-chain** (`NetworkService.getAccount`) |
+| `resolveAccountAddress` | Given an address, return CAIP-10 if this snap owns it; else `null` (MetaMask may fall back) | **Snap state** (keyring account lookup by address) |
+| `filterAccountChains` | Not implemented | Throws `MethodNotSupportedError` |
+| `updateAccount` | Not implemented | Throws `MethodNotSupportedError` |
+| `submitRequest` | [signTransaction.md](./signTransaction.md) · [signMessage.md](./signMessage.md) · [signAuthEntry.md](./signAuthEntry.md) | |
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signAuthEntry.md b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signAuthEntry.md
new file mode 100644
index 00000000..572c6d23
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signAuthEntry.md
@@ -0,0 +1,58 @@
+# Use case: `signAuthEntry`
+
+SEP-43: confirm and sign a Soroban authorization preimage (`HashIdPreimage` / `envelopeTypeSorobanAuthorization`).
+
+| | |
+| ------------ | ------------------------------------------------------------------------------------- |
+| **Entry** | `onKeyringRequest` → `KeyringHandler.submitRequest` → `SignAuthEntryHandler` |
+| **Method** | `signAuthEntry` (`MultichainMethod.SignAuthEntry`) |
+| **Source** | [`handlers/keyring/signAuthEntry.ts`](../../../src/handlers/keyring/signAuthEntry.ts) |
+| **Overview** | [keyring.md](./keyring.md) |
+
+## Participants
+
+| Component | Path | Role |
+| -------------------------- | ------------------ | ----------------------------- |
+| `SignAuthEntryHandler` | `handlers/keyring` | Decode, confirm, sign |
+| `AccountResolver` | `handlers/` | Load keyring account + wallet |
+| `Wallet` | `services/wallet` | `signAuthEntry` |
+| `ConfirmationUXController` | `ui/confirmation` | Sign-auth-entry dialog |
+
+## Request / response
+
+Wire format follows **SEP-43** `signAuthEntry` (via keyring `submitRequest`):
+
+- [SEP-43](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md)
+- Keyring transport: [Account Management API](https://docs.metamask.io/snaps/reference/keyring-api/account-management/) (`keyring_submitRequest`)
+
+Local validators: [`handlers/keyring/api.ts`](../../../src/handlers/keyring/api.ts) (`SignAuthEntryRequestStruct` / `SignAuthEntryResponseStruct`).
+
+The dapp supplies a base64 `HashIdPreimage`. The Snap decodes it for the confirmation UI (contract, function, args, nested invocations, nonce, expiry ledger), then on approve signs `sha256(preimage)` with ed25519. Network id is already inside the preimage (mainnet-only validation at the struct layer).
+
+## Step-by-step
+
+1. Resolve account + wallet.
+2. Decode preimage into a readable auth summary for the UI.
+3. Show confirmation.
+4. On approve → `wallet.signAuthEntry` → return signature.
+5. On reject → SEP-43 error envelope.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Dapp
+ participant Handler as SignAuthEntryHandler
+ participant UI as ConfirmationUXController
+ participant Wallet
+
+ Dapp->>Handler: signAuthEntry (SEP-43 via keyring submitRequest)
+ Handler->>Handler: decode Soroban auth preimage
+ Handler->>UI: confirmation dialog
+ alt user rejects
+ Handler-->>Dapp: SEP-43 error envelope
+ else user confirms
+ Handler->>Wallet: signAuthEntry
+ Handler-->>Dapp: SEP-43 success (signedAuthEntry, signerAddress)
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signMessage.md b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signMessage.md
new file mode 100644
index 00000000..30e15c15
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signMessage.md
@@ -0,0 +1,73 @@
+# Use case: `signMessage`
+
+SEP-43: confirm and sign an arbitrary UTF-8 message.
+
+| | |
+| ------------ | --------------------------------------------------------------------------------- |
+| **Entry** | `onKeyringRequest` → `KeyringHandler.submitRequest` → `SignMessageHandler` |
+| **Method** | `signMessage` (`MultichainMethod.SignMessage`) |
+| **Source** | [`handlers/keyring/signMessage.ts`](../../../src/handlers/keyring/signMessage.ts) |
+| **Overview** | [keyring.md](./keyring.md) |
+
+## Participants
+
+| Component | Path | Role |
+| -------------------------- | ------------------ | ----------------------------- |
+| `SignMessageHandler` | `handlers/keyring` | Confirm + sign |
+| `AccountResolver` | `handlers/` | Load keyring account + wallet |
+| `Wallet` | `services/wallet` | `signMessage` |
+| `ConfirmationUXController` | `ui/confirmation` | Sign-message dialog |
+
+## Request / response
+
+Wire format follows **SEP-43** `signMessage` (via keyring `submitRequest`):
+
+- [SEP-43](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md) — keyring method + params/response envelope
+- Keyring transport: [Account Management API](https://docs.metamask.io/snaps/reference/keyring-api/account-management/) (`keyring_submitRequest`)
+
+Local validators: [`handlers/keyring/api.ts`](../../../src/handlers/keyring/api.ts) (`SignMessageRequestStruct` / `SignMessageResponseStruct`).
+
+This snap accepts **UTF-8 text only** for `message` (non-empty). Base64-encoded binary payloads are not auto-detected.
+
+## How signing works
+
+Signing follows the **Stellar Signed Message** protocol ([SEP-0053](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md)), implemented in [`Wallet.signMessage`](../../../src/services/wallet/Wallet.ts):
+
+1. **Interpret `message`** — always as UTF-8 text (string → UTF-8 bytes).
+2. **Build the payload** — prepend the fixed prefix `Stellar Signed Message:\n`, then append the message bytes.
+3. **Hash** — SHA-256 over that byte sequence.
+4. **Sign** — Ed25519 sign the digest with the account’s keypair.
+5. **Return** — base64-encoded signature as `signedMessage` (SEP-43 response).
+
+The confirmation UI shows the UTF-8 `message` string as given — the same content that gets signed.
+
+**References**
+
+| Spec | Role |
+| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| [SEP-43](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md) | Keyring `signMessage` request/response wire format |
+| [SEP-0053](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md) | Stellar Signed Message prefix, hash, and signature algorithm |
+
+## Step-by-step
+
+1. Resolve keyring account + wallet for the signer.
+2. Show confirmation with the UTF-8 message text.
+3. On approve → `Wallet.signMessage` (SEP-0053, UTF-8) → return `signedMessage` + `signerAddress`.
+4. On reject → SEP-43 error envelope.
+
+```mermaid
+sequenceDiagram
+ participant Dapp
+ participant Handler as SignMessageHandler
+ participant UI as ConfirmationUXController
+ participant Wallet
+
+ Dapp->>Handler: signMessage (SEP-43 via keyring submitRequest)
+ Handler->>UI: confirmation dialog (UTF-8 message)
+ alt user rejects
+ Handler-->>Dapp: SEP-43 error envelope
+ else user confirms
+ Handler->>Wallet: signMessage (SEP-0053, UTF-8)
+ Handler-->>Dapp: SEP-43 success (signedMessage, signerAddress)
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signTransaction.md b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signTransaction.md
new file mode 100644
index 00000000..61802589
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/keyring/signTransaction.md
@@ -0,0 +1,67 @@
+# Use case: `signTransaction`
+
+SEP-43: show a confirmation, then return a **signed** transaction XDR to the dapp. The Snap does **not** change the envelope and does **not** broadcast it.
+
+| | |
+| ------------ | ----------------------------------------------------------------------------------------- |
+| **Entry** | `onKeyringRequest` → `KeyringHandler.submitRequest` → `SignTransactionHandler` |
+| **Method** | `signTransaction` (`MultichainMethod.SignTransaction`) |
+| **Source** | [`handlers/keyring/signTransaction.ts`](../../../src/handlers/keyring/signTransaction.ts) |
+| **Overview** | [keyring.md](./keyring.md) |
+
+## Participants
+
+| Component | Path | Role |
+| -------------------------- | --------------------------- | ------------------------------------------------------ |
+| `SignTransactionHandler` | `handlers/keyring` | Decode, confirm, sign |
+| `AccountResolver` | `handlers/` | Load keyring account + wallet |
+| `Wallet` | `services/wallet` | `signTransaction` |
+| `ConfirmationUXController` | `ui/confirmation` | Sign-transaction dialog |
+| `TransactionScanService` | `services/transaction-scan` | Security scan + remote simulation while dialog is open |
+
+## Request / response
+
+Wire format follows **SEP-43** `signTransaction` (and MetaMask keyring `submitRequest` wrapping):
+
+- [SEP-43](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md)
+- Keyring transport: [Account Management API](https://docs.metamask.io/snaps/reference/keyring-api/account-management/) (`keyring_submitRequest`)
+
+Local Superstruct validators live in [`handlers/keyring/api.ts`](../../../src/handlers/keyring/api.ts) (`SignTransactionRequestStruct` / `SignTransactionResponseStruct`).
+
+## Important notes
+
+- **No modification** — the Snap does not rebuild, re-fee, or re-simulate the transaction for signing. It decodes the dapp-supplied XDR, checks scope, and signs as-is. Balance / op-level validation is the caller’s responsibility.
+- **No broadcast** — after signing, the Snap returns the SEP-43 success fields (`signedTxXdr`, `signerAddress`). Submitting to the network is entirely the dapp’s job (unlike [`confirmSend`](../client-request/confirmSend.md) / [`signAndSendTransaction`](../client-request/signAndSendTransaction.md)).
+- Fee on the envelope is trusted as provided by the dapp; security scan / remote simulation may still surface issues in the confirmation UI.
+- Failures / user reject are returned in the SEP-43 `error` envelope (does not throw to the dapp).
+
+## Step-by-step
+
+1. Resolve account + wallet for the signer.
+2. `Transaction.fromXdr` + assert scope matches.
+3. Show confirmation (readable ops, fee, prices, security scan / remote simulation).
+4. On approve → `wallet.signTransaction` → return signed XDR.
+5. On reject → user-rejected path mapped to SEP-43 error response.
+
+## Sequence
+
+```mermaid
+sequenceDiagram
+ participant Dapp
+ participant Handler as SignTransactionHandler
+ participant UI as ConfirmationUXController
+ participant Wallet
+
+ Dapp->>Handler: signTransaction (SEP-43 via keyring submitRequest)
+ Handler->>Handler: fromXdr (no rebuild / no balance validate)
+ Handler->>UI: confirmation dialog
+ alt user rejects
+ UI-->>Handler: rejected
+ Handler-->>Dapp: SEP-43 error envelope
+ else user confirms
+ UI-->>Handler: confirmed
+ Handler->>Wallet: signTransaction
+ Handler-->>Dapp: SEP-43 success (signedTxXdr, signerAddress)
+ Note over Dapp: Dapp broadcasts if needed
+ end
+```
diff --git a/merged-packages/stellar-wallet-snap/docs/use-cases/user-input/userInput.md b/merged-packages/stellar-wallet-snap/docs/use-cases/user-input/userInput.md
new file mode 100644
index 00000000..39b03c6a
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/docs/use-cases/user-input/userInput.md
@@ -0,0 +1,29 @@
+# Use case: user input (`UserInputHandler`)
+
+Interactive UI events via `onUserInput` → `UserInputHandler`.
+
+| | |
+| ---------- | ----------------------------------------------------------------------------------- |
+| **Entry** | `onUserInput` → `UserInputHandler.handle` |
+| **Source** | [`handlers/user-input/userInput.ts`](../../../src/handlers/user-input/userInput.ts) |
+
+## Participants
+
+| Component | Path | Role |
+| ------------------------ | -------------------------------- | --------------------------------------------------------------- |
+| `UserInputHandler` | `handlers/user-input` | Route UI events by `event.name` |
+| Confirmation view events | `ui/confirmation/views/*/events` | Confirm / cancel (and related) handlers that resolve the dialog |
+
+## Request / response
+
+Wire format follows the MetaMask **interactive UI** entry point:
+
+- [Entry points — `onUserInput`](https://docs.metamask.io/snaps/reference/entry-points/#onuserinput)
+
+MetaMask calls `onUserInput` with `{ id, event, context }` when the user interacts with a Snap interface. This Snap ignores events without a `name`, then dispatches to a handler map built from confirmation views.
+
+## Methods
+
+| Method | What it does | Data source |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------- |
+| `onUserInput` | Route named UI events to confirmation handlers (sign message / tx / auth entry, change trust, send, malicious acknowledgement) | |
diff --git a/merged-packages/stellar-wallet-snap/images/icon.svg b/merged-packages/stellar-wallet-snap/images/icon.svg
new file mode 100644
index 00000000..2165da62
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/images/icon.svg
@@ -0,0 +1 @@
+
diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js
new file mode 100644
index 00000000..859b3a37
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/jest.config.js
@@ -0,0 +1,56 @@
+// @ts-check
+/**
+ * @type {import('ts-jest').JestConfigWithTsJest}
+ */
+const config = {
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: false,
+
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'],
+
+ // The directory where Jest should output its coverage files
+ coverageDirectory: 'coverage',
+
+ // An array of regexp pattern strings used to skip coverage collection
+ coveragePathIgnorePatterns: [
+ '.*/index\\.ts$', // any index.ts
+ '.*/constants\\.ts$', // any file named constants.ts
+ '.*/constants/', // any file in a folder named constants
+ '.*/utils/logger\\.ts$', // skip logger.ts
+ '.*/permissions\\.ts$', // skip permissions.ts
+ '.*/context\\.ts$', // skip context.ts
+ '.*/config\\.ts$', // skip config.ts
+ '.*/utils/snap\\.ts$', // skip snap.ts
+ ],
+
+ // Indicates which provider should be used to instrument code for coverage
+ coverageProvider: 'babel',
+
+ // A list of reporter names that Jest uses when writing coverage reports
+ coverageReporters: ['text', 'html', 'json-summary', 'lcov'],
+
+ // An object that configures minimum threshold enforcement for coverage results
+ coverageThreshold: {
+ global: {
+ branches: 60.96,
+ functions: 75.86,
+ lines: 77.51,
+ statements: 77.69,
+ },
+ },
+
+ preset: '@metamask/snaps-jest',
+ transform: {
+ '^.+\\.(t|j)sx?$': 'ts-jest',
+ },
+ moduleNameMapper: {
+ '\\.svg$': 'jest-transform-stub',
+ },
+ resetMocks: true,
+ restoreMocks: true,
+ testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'],
+ setupFilesAfterEnv: ['/jest.setup.ts'],
+};
+
+module.exports = config;
diff --git a/merged-packages/stellar-wallet-snap/jest.integration.config.mjs b/merged-packages/stellar-wallet-snap/jest.integration.config.mjs
new file mode 100644
index 00000000..c36c839e
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/jest.integration.config.mjs
@@ -0,0 +1,10 @@
+// @ts-check
+/**
+ * @type {import('ts-jest').JestConfigWithTsJest}
+ */
+const config = {
+ preset: '@metamask/snaps-jest',
+ testMatch: ['**/integration-test/**/*.test.ts'],
+};
+
+export default config;
diff --git a/merged-packages/stellar-wallet-snap/jest.setup.ts b/merged-packages/stellar-wallet-snap/jest.setup.ts
new file mode 100644
index 00000000..7adb2fb2
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/jest.setup.ts
@@ -0,0 +1,7 @@
+import { config } from 'dotenv';
+
+config();
+
+// Set default environment for tests if not already set
+// eslint-disable-next-line no-restricted-globals
+process.env.ENVIRONMENT ??= 'test';
diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json
new file mode 100644
index 00000000..eaa949d0
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/locales/en.json
@@ -0,0 +1,488 @@
+{
+ "locale": "en",
+ "messages": {
+ "confirmation.transaction.title": {
+ "message": "Transaction request"
+ },
+ "confirmation.estimatedChanges.title": {
+ "message": "Estimated changes"
+ },
+ "confirmation.estimatedChanges.tooltip": {
+ "message": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee."
+ },
+ "confirmation.estimatedChanges.send": {
+ "message": "You send"
+ },
+ "confirmation.estimatedChanges.receive": {
+ "message": "You receive"
+ },
+ "confirmation.origin": {
+ "message": "Request from"
+ },
+ "confirmation.origin.tooltip": {
+ "message": "This is the site asking for your confirmation."
+ },
+ "confirmation.from": {
+ "message": "From"
+ },
+ "confirmation.to": {
+ "message": "To"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Network fee"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirm"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancel"
+ },
+ "confirmation.reviewAlertsButton": {
+ "message": "Review alert"
+ },
+ "confirmation.maliciousAck.title": {
+ "message": "Malicious request"
+ },
+ "confirmation.maliciousAck.description": {
+ "message": "If you confirm this request, you will probably lose your assets to a scammer."
+ },
+ "confirmation.maliciousAck.checkbox": {
+ "message": "I have acknowledged the risk and still want to proceed"
+ },
+ "confirmation.maliciousAck.proceed": {
+ "message": "Confirm"
+ },
+ "confirmation.maliciousAck.back": {
+ "message": "Go back"
+ },
+ "confirmation.closeButton": {
+ "message": "Close"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Sign message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.signAuthEntry.title": {
+ "message": "Authorize smart contract"
+ },
+ "confirmation.signAuthEntry.warning": {
+ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site."
+ },
+ "confirmation.signAuthEntry.contract": {
+ "message": "Contract Address"
+ },
+ "confirmation.signAuthEntry.function": {
+ "message": "Function Name"
+ },
+ "confirmation.signAuthEntry.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.signAuthEntry.expiresAt": {
+ "message": "Expires at ledger"
+ },
+ "confirmation.signAuthEntry.nonce": {
+ "message": "Nonce"
+ },
+ "confirmation.signAuthEntry.subInvocations": {
+ "message": "Nested authorizations"
+ },
+ "confirmation.signAuthEntry.createContract": {
+ "message": "Deploy contract"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.memo": {
+ "message": "Memo"
+ },
+ "confirmation.asset": {
+ "message": "Asset"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Unsupported contract for simulation"
+ },
+ "confirmation.securityScanInProgressTitle": {
+ "message": "Checking for security issues"
+ },
+ "confirmation.securityScanInProgressMessage": {
+ "message": "This usually takes a few seconds."
+ },
+ "confirmation.securityScanAPIErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanAPIErrorMessage": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.securityScanErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.securityScanIncompleteTitle": {
+ "message": "Security scan incomplete"
+ },
+ "confirmation.securityScanIncompleteSubtitle": {
+ "message": "{reason}. Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction is expected to fail."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.transactionInvalidTitle": {
+ "message": "Transaction is no longer valid"
+ },
+ "confirmation.transactionInvalidSubtitle": {
+ "message": "It may have expired or your account balance changed. Close this request and try again."
+ },
+ "confirmation.validationScanErrorTitle": {
+ "message": "Security check unavailable"
+ },
+ "confirmation.validationScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationWarningTitle": {
+ "message": "This request may be risky"
+ },
+ "confirmation.validationWarningSubtitle": {
+ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "See details"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Powered by"
+ },
+ "confirmation.transaction.accountmerge": {
+ "message": "Merge account"
+ },
+ "confirmation.transaction.allowtrust": {
+ "message": "Allow trust"
+ },
+ "confirmation.transaction.beginsponsoringfuturereserves": {
+ "message": "Begin sponsoring future reserves"
+ },
+ "confirmation.transaction.bumpsequence": {
+ "message": "Bump sequence"
+ },
+ "confirmation.transaction.changetrust": {
+ "message": "Change trust line"
+ },
+ "confirmation.transaction.claimclaimablebalance": {
+ "message": "Claim claimable balance"
+ },
+ "confirmation.transaction.clawback": {
+ "message": "Clawback"
+ },
+ "confirmation.transaction.clawbackclaimablebalance": {
+ "message": "Clawback claimable balance"
+ },
+ "confirmation.transaction.createaccount": {
+ "message": "Create account"
+ },
+ "confirmation.transaction.createclaimablebalance": {
+ "message": "Create claimable balance"
+ },
+ "confirmation.transaction.createpassiveselloffer": {
+ "message": "Create passive sell offer"
+ },
+ "confirmation.transaction.endsponsoringfuturereserves": {
+ "message": "End sponsoring future reserves"
+ },
+ "confirmation.transaction.extendfootprintttl": {
+ "message": "Extend footprint TTL"
+ },
+ "confirmation.transaction.inflation": {
+ "message": "Inflation"
+ },
+ "confirmation.transaction.invokehostfunction": {
+ "message": "Invoke host function"
+ },
+ "confirmation.transaction.liquiditypooldeposit": {
+ "message": "Liquidity pool deposit"
+ },
+ "confirmation.transaction.liquiditypoolwithdraw": {
+ "message": "Liquidity pool withdraw"
+ },
+ "confirmation.transaction.managedata": {
+ "message": "Manage data"
+ },
+ "confirmation.transaction.managebuyoffer": {
+ "message": "Manage buy offer"
+ },
+ "confirmation.transaction.manageselloffer": {
+ "message": "Manage sell offer"
+ },
+ "confirmation.transaction.pathpaymentstrictreceive": {
+ "message": "Path payment (strict receive)"
+ },
+ "confirmation.transaction.pathpaymentstrictsend": {
+ "message": "Path payment (strict send)"
+ },
+ "confirmation.transaction.payment": {
+ "message": "Payment"
+ },
+ "confirmation.transaction.restorefootprint": {
+ "message": "Restore footprint"
+ },
+ "confirmation.transaction.revokesponsorship": {
+ "message": "Revoke sponsorship"
+ },
+ "confirmation.transaction.setoptions": {
+ "message": "Set options"
+ },
+ "confirmation.transaction.settrustlineflags": {
+ "message": "Set trust line flags"
+ },
+ "confirmation.transaction.param.account": {
+ "message": "Account"
+ },
+ "confirmation.transaction.param.amount": {
+ "message": "Amount"
+ },
+ "confirmation.transaction.param.asset": {
+ "message": "Asset"
+ },
+ "confirmation.transaction.param.assetCode": {
+ "message": "Asset code"
+ },
+ "confirmation.transaction.param.authorize": {
+ "message": "Authorize"
+ },
+ "confirmation.transaction.param.balanceId": {
+ "message": "Balance ID"
+ },
+ "confirmation.transaction.param.buyAmount": {
+ "message": "Buy amount"
+ },
+ "confirmation.transaction.param.bumpTo": {
+ "message": "Bump to"
+ },
+ "confirmation.transaction.param.buying": {
+ "message": "Buying"
+ },
+ "confirmation.transaction.param.claimants": {
+ "message": "Claimants"
+ },
+ "confirmation.transaction.param.clearFlags": {
+ "message": "Clear flags"
+ },
+ "confirmation.transaction.param.contractId": {
+ "message": "Contract Address"
+ },
+ "confirmation.transaction.param.destAmount": {
+ "message": "Destination amount"
+ },
+ "confirmation.transaction.param.destAsset": {
+ "message": "Destination asset"
+ },
+ "confirmation.transaction.param.destMin": {
+ "message": "Destination minimum"
+ },
+ "confirmation.transaction.param.destination": {
+ "message": "Destination"
+ },
+ "confirmation.transaction.param.extendTo": {
+ "message": "Extend to"
+ },
+ "confirmation.transaction.param.flags": {
+ "message": "Flags"
+ },
+ "confirmation.transaction.param.functionName": {
+ "message": "Function"
+ },
+ "confirmation.transaction.param.arguments": {
+ "message": "Arguments"
+ },
+ "confirmation.transaction.param.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.transaction.param.from": {
+ "message": "From"
+ },
+ "confirmation.transaction.param.highThreshold": {
+ "message": "High threshold"
+ },
+ "confirmation.transaction.param.homeDomain": {
+ "message": "Home domain"
+ },
+ "confirmation.transaction.param.hostFunctionXdrBase64": {
+ "message": "Host function (XDR, base64)"
+ },
+ "confirmation.transaction.param.inflationDest": {
+ "message": "Inflation destination"
+ },
+ "confirmation.transaction.param.limit": {
+ "message": "Limit"
+ },
+ "confirmation.transaction.param.line": {
+ "message": "Trust line"
+ },
+ "confirmation.transaction.param.liquidityPoolId": {
+ "message": "Liquidity pool ID"
+ },
+ "confirmation.transaction.param.lowThreshold": {
+ "message": "Low threshold"
+ },
+ "confirmation.transaction.param.masterWeight": {
+ "message": "Master weight"
+ },
+ "confirmation.transaction.param.maxAmountA": {
+ "message": "Max amount A"
+ },
+ "confirmation.transaction.param.maxAmountB": {
+ "message": "Max amount B"
+ },
+ "confirmation.transaction.param.maxPrice": {
+ "message": "Max price"
+ },
+ "confirmation.transaction.param.medThreshold": {
+ "message": "Medium threshold"
+ },
+ "confirmation.transaction.param.minAmountA": {
+ "message": "Min amount A"
+ },
+ "confirmation.transaction.param.minAmountB": {
+ "message": "Min amount B"
+ },
+ "confirmation.transaction.param.minPrice": {
+ "message": "Min price"
+ },
+ "confirmation.transaction.param.name": {
+ "message": "Name"
+ },
+ "confirmation.transaction.param.note": {
+ "message": "Note"
+ },
+ "confirmation.transaction.param.offerId": {
+ "message": "Offer ID"
+ },
+ "confirmation.transaction.param.path": {
+ "message": "Path"
+ },
+ "confirmation.transaction.param.price": {
+ "message": "Price"
+ },
+ "confirmation.transaction.param.seller": {
+ "message": "Seller"
+ },
+ "confirmation.transaction.param.sendAmount": {
+ "message": "Send amount"
+ },
+ "confirmation.transaction.param.sendAsset": {
+ "message": "Send asset"
+ },
+ "confirmation.transaction.param.sendMax": {
+ "message": "Send max"
+ },
+ "confirmation.transaction.param.selling": {
+ "message": "Selling"
+ },
+ "confirmation.transaction.param.setFlags": {
+ "message": "Set flags"
+ },
+ "confirmation.transaction.param.signer": {
+ "message": "Signer"
+ },
+ "confirmation.transaction.param.signerEd25519": {
+ "message": "Signer (public key)"
+ },
+ "confirmation.transaction.param.signerSha256Hash": {
+ "message": "Signer (SHA-256)"
+ },
+ "confirmation.transaction.param.signerPreAuthTx": {
+ "message": "Signer (pre-auth tx)"
+ },
+ "confirmation.transaction.param.signerSignedPayload": {
+ "message": "Signer (signed payload)"
+ },
+ "confirmation.transaction.param.signerWeight": {
+ "message": "Signer weight"
+ },
+ "confirmation.transaction.param.sponsoredId": {
+ "message": "Sponsored account"
+ },
+ "confirmation.transaction.param.startingBalance": {
+ "message": "Starting balance"
+ },
+ "confirmation.transaction.param.source": {
+ "message": "Source"
+ },
+ "confirmation.transaction.param.trustor": {
+ "message": "Trustor"
+ },
+ "confirmation.transaction.param.valueBase64": {
+ "message": "Value (base64)"
+ },
+ "confirmation.signChangeTrustOptIn.title": {
+ "message": "Add {asset} trustline"
+ },
+ "confirmation.signChangeTrustOptIn.updateTitle": {
+ "message": "Update {asset} trustline limit"
+ },
+ "confirmation.signChangeTrustOptOut.title": {
+ "message": "Remove {asset} trustline"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Insufficient funds"
+ },
+ "transactionScan.errors.noTrustline": {
+ "message": "Trustline not found"
+ },
+ "transactionScan.errors.transactionExpired": {
+ "message": "Transaction expired"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Invalid address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Unsupported method"
+ },
+ "confirmation.accountActivation.title": {
+ "message": "Activate Stellar Wallet"
+ },
+ "confirmation.accountActivation.description": {
+ "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets."
+ },
+ "confirmation.accountActivation.callToAction": {
+ "message": "Add XLM to your wallet to get started."
+ },
+ "confirmation.accountActivation.address": {
+ "message": "Stellar address"
+ },
+ "confirmation.accountActivation.copyAddress": {
+ "message": "Copy Address"
+ }
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json
new file mode 100644
index 00000000..f2771f1b
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/locales/es.json
@@ -0,0 +1,488 @@
+{
+ "locale": "es",
+ "messages": {
+ "confirmation.transaction.title": {
+ "message": "Transaction request"
+ },
+ "confirmation.estimatedChanges.title": {
+ "message": "Estimated changes"
+ },
+ "confirmation.estimatedChanges.tooltip": {
+ "message": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee."
+ },
+ "confirmation.estimatedChanges.send": {
+ "message": "You send"
+ },
+ "confirmation.estimatedChanges.receive": {
+ "message": "You receive"
+ },
+ "confirmation.origin": {
+ "message": "Request from"
+ },
+ "confirmation.origin.tooltip": {
+ "message": "This is the site asking for your confirmation."
+ },
+ "confirmation.from": {
+ "message": "From"
+ },
+ "confirmation.to": {
+ "message": "To"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Network fee"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirm"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancel"
+ },
+ "confirmation.reviewAlertsButton": {
+ "message": "Review alert"
+ },
+ "confirmation.maliciousAck.title": {
+ "message": "Malicious request"
+ },
+ "confirmation.maliciousAck.description": {
+ "message": "If you confirm this request, you will probably lose your assets to a scammer."
+ },
+ "confirmation.maliciousAck.checkbox": {
+ "message": "I have acknowledged the risk and still want to proceed"
+ },
+ "confirmation.maliciousAck.proceed": {
+ "message": "Confirm"
+ },
+ "confirmation.maliciousAck.back": {
+ "message": "Go back"
+ },
+ "confirmation.closeButton": {
+ "message": "Close"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Sign message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.signAuthEntry.title": {
+ "message": "Authorize smart contract"
+ },
+ "confirmation.signAuthEntry.warning": {
+ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site."
+ },
+ "confirmation.signAuthEntry.contract": {
+ "message": "Contract Address"
+ },
+ "confirmation.signAuthEntry.function": {
+ "message": "Function Name"
+ },
+ "confirmation.signAuthEntry.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.signAuthEntry.expiresAt": {
+ "message": "Expires at ledger"
+ },
+ "confirmation.signAuthEntry.nonce": {
+ "message": "Nonce"
+ },
+ "confirmation.signAuthEntry.subInvocations": {
+ "message": "Nested authorizations"
+ },
+ "confirmation.signAuthEntry.createContract": {
+ "message": "Deploy contract"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.memo": {
+ "message": "Memo"
+ },
+ "confirmation.asset": {
+ "message": "Asset"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Unsupported contract for simulation"
+ },
+ "confirmation.securityScanInProgressTitle": {
+ "message": "Checking for security issues"
+ },
+ "confirmation.securityScanInProgressMessage": {
+ "message": "This usually takes a few seconds."
+ },
+ "confirmation.securityScanAPIErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanAPIErrorMessage": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.securityScanErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.securityScanIncompleteTitle": {
+ "message": "Security scan incomplete"
+ },
+ "confirmation.securityScanIncompleteSubtitle": {
+ "message": "{reason}. Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction is expected to fail."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.transactionInvalidTitle": {
+ "message": "Transaction is no longer valid"
+ },
+ "confirmation.transactionInvalidSubtitle": {
+ "message": "It may have expired or your account balance changed. Close this request and try again."
+ },
+ "confirmation.validationScanErrorTitle": {
+ "message": "Security check unavailable"
+ },
+ "confirmation.validationScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationWarningTitle": {
+ "message": "This request may be risky"
+ },
+ "confirmation.validationWarningSubtitle": {
+ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "See details"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Powered by"
+ },
+ "confirmation.transaction.accountmerge": {
+ "message": "Merge account"
+ },
+ "confirmation.transaction.allowtrust": {
+ "message": "Allow trust"
+ },
+ "confirmation.transaction.beginsponsoringfuturereserves": {
+ "message": "Begin sponsoring future reserves"
+ },
+ "confirmation.transaction.bumpsequence": {
+ "message": "Bump sequence"
+ },
+ "confirmation.transaction.changetrust": {
+ "message": "Change trust line"
+ },
+ "confirmation.transaction.claimclaimablebalance": {
+ "message": "Claim claimable balance"
+ },
+ "confirmation.transaction.clawback": {
+ "message": "Clawback"
+ },
+ "confirmation.transaction.clawbackclaimablebalance": {
+ "message": "Clawback claimable balance"
+ },
+ "confirmation.transaction.createaccount": {
+ "message": "Create account"
+ },
+ "confirmation.transaction.createclaimablebalance": {
+ "message": "Create claimable balance"
+ },
+ "confirmation.transaction.createpassiveselloffer": {
+ "message": "Create passive sell offer"
+ },
+ "confirmation.transaction.endsponsoringfuturereserves": {
+ "message": "End sponsoring future reserves"
+ },
+ "confirmation.transaction.extendfootprintttl": {
+ "message": "Extend footprint TTL"
+ },
+ "confirmation.transaction.inflation": {
+ "message": "Inflation"
+ },
+ "confirmation.transaction.invokehostfunction": {
+ "message": "Invoke host function"
+ },
+ "confirmation.transaction.liquiditypooldeposit": {
+ "message": "Liquidity pool deposit"
+ },
+ "confirmation.transaction.liquiditypoolwithdraw": {
+ "message": "Liquidity pool withdraw"
+ },
+ "confirmation.transaction.managedata": {
+ "message": "Manage data"
+ },
+ "confirmation.transaction.managebuyoffer": {
+ "message": "Manage buy offer"
+ },
+ "confirmation.transaction.manageselloffer": {
+ "message": "Manage sell offer"
+ },
+ "confirmation.transaction.pathpaymentstrictreceive": {
+ "message": "Path payment (strict receive)"
+ },
+ "confirmation.transaction.pathpaymentstrictsend": {
+ "message": "Path payment (strict send)"
+ },
+ "confirmation.transaction.payment": {
+ "message": "Payment"
+ },
+ "confirmation.transaction.restorefootprint": {
+ "message": "Restore footprint"
+ },
+ "confirmation.transaction.revokesponsorship": {
+ "message": "Revoke sponsorship"
+ },
+ "confirmation.transaction.setoptions": {
+ "message": "Set options"
+ },
+ "confirmation.transaction.settrustlineflags": {
+ "message": "Set trust line flags"
+ },
+ "confirmation.transaction.param.account": {
+ "message": "Account"
+ },
+ "confirmation.transaction.param.amount": {
+ "message": "Amount"
+ },
+ "confirmation.transaction.param.asset": {
+ "message": "Asset"
+ },
+ "confirmation.transaction.param.assetCode": {
+ "message": "Asset code"
+ },
+ "confirmation.transaction.param.authorize": {
+ "message": "Authorize"
+ },
+ "confirmation.transaction.param.balanceId": {
+ "message": "Balance ID"
+ },
+ "confirmation.transaction.param.buyAmount": {
+ "message": "Buy amount"
+ },
+ "confirmation.transaction.param.bumpTo": {
+ "message": "Bump to"
+ },
+ "confirmation.transaction.param.buying": {
+ "message": "Buying"
+ },
+ "confirmation.transaction.param.claimants": {
+ "message": "Claimants"
+ },
+ "confirmation.transaction.param.clearFlags": {
+ "message": "Clear flags"
+ },
+ "confirmation.transaction.param.contractId": {
+ "message": "Contract Address"
+ },
+ "confirmation.transaction.param.destAmount": {
+ "message": "Destination amount"
+ },
+ "confirmation.transaction.param.destAsset": {
+ "message": "Destination asset"
+ },
+ "confirmation.transaction.param.destMin": {
+ "message": "Destination minimum"
+ },
+ "confirmation.transaction.param.destination": {
+ "message": "Destination"
+ },
+ "confirmation.transaction.param.extendTo": {
+ "message": "Extend to"
+ },
+ "confirmation.transaction.param.flags": {
+ "message": "Flags"
+ },
+ "confirmation.transaction.param.functionName": {
+ "message": "Function"
+ },
+ "confirmation.transaction.param.arguments": {
+ "message": "Arguments"
+ },
+ "confirmation.transaction.param.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.transaction.param.from": {
+ "message": "From"
+ },
+ "confirmation.transaction.param.highThreshold": {
+ "message": "High threshold"
+ },
+ "confirmation.transaction.param.homeDomain": {
+ "message": "Home domain"
+ },
+ "confirmation.transaction.param.hostFunctionXdrBase64": {
+ "message": "Host function (XDR, base64)"
+ },
+ "confirmation.transaction.param.inflationDest": {
+ "message": "Inflation destination"
+ },
+ "confirmation.transaction.param.limit": {
+ "message": "Limit"
+ },
+ "confirmation.transaction.param.line": {
+ "message": "Trust line"
+ },
+ "confirmation.transaction.param.liquidityPoolId": {
+ "message": "Liquidity pool ID"
+ },
+ "confirmation.transaction.param.lowThreshold": {
+ "message": "Low threshold"
+ },
+ "confirmation.transaction.param.masterWeight": {
+ "message": "Master weight"
+ },
+ "confirmation.transaction.param.maxAmountA": {
+ "message": "Max amount A"
+ },
+ "confirmation.transaction.param.maxAmountB": {
+ "message": "Max amount B"
+ },
+ "confirmation.transaction.param.maxPrice": {
+ "message": "Max price"
+ },
+ "confirmation.transaction.param.medThreshold": {
+ "message": "Medium threshold"
+ },
+ "confirmation.transaction.param.minAmountA": {
+ "message": "Min amount A"
+ },
+ "confirmation.transaction.param.minAmountB": {
+ "message": "Min amount B"
+ },
+ "confirmation.transaction.param.minPrice": {
+ "message": "Min price"
+ },
+ "confirmation.transaction.param.name": {
+ "message": "Name"
+ },
+ "confirmation.transaction.param.note": {
+ "message": "Note"
+ },
+ "confirmation.transaction.param.offerId": {
+ "message": "Offer ID"
+ },
+ "confirmation.transaction.param.path": {
+ "message": "Path"
+ },
+ "confirmation.transaction.param.price": {
+ "message": "Price"
+ },
+ "confirmation.transaction.param.seller": {
+ "message": "Seller"
+ },
+ "confirmation.transaction.param.sendAmount": {
+ "message": "Send amount"
+ },
+ "confirmation.transaction.param.sendAsset": {
+ "message": "Send asset"
+ },
+ "confirmation.transaction.param.sendMax": {
+ "message": "Send max"
+ },
+ "confirmation.transaction.param.selling": {
+ "message": "Selling"
+ },
+ "confirmation.transaction.param.setFlags": {
+ "message": "Set flags"
+ },
+ "confirmation.transaction.param.signer": {
+ "message": "Signer"
+ },
+ "confirmation.transaction.param.signerEd25519": {
+ "message": "Signer (public key)"
+ },
+ "confirmation.transaction.param.signerSha256Hash": {
+ "message": "Signer (SHA-256)"
+ },
+ "confirmation.transaction.param.signerPreAuthTx": {
+ "message": "Signer (pre-auth tx)"
+ },
+ "confirmation.transaction.param.signerSignedPayload": {
+ "message": "Signer (signed payload)"
+ },
+ "confirmation.transaction.param.signerWeight": {
+ "message": "Signer weight"
+ },
+ "confirmation.transaction.param.sponsoredId": {
+ "message": "Sponsored account"
+ },
+ "confirmation.transaction.param.startingBalance": {
+ "message": "Starting balance"
+ },
+ "confirmation.transaction.param.source": {
+ "message": "Source"
+ },
+ "confirmation.transaction.param.trustor": {
+ "message": "Trustor"
+ },
+ "confirmation.transaction.param.valueBase64": {
+ "message": "Value (base64)"
+ },
+ "confirmation.signChangeTrustOptIn.title": {
+ "message": "Add {asset} trustline"
+ },
+ "confirmation.signChangeTrustOptIn.updateTitle": {
+ "message": "Update {asset} trustline limit"
+ },
+ "confirmation.signChangeTrustOptOut.title": {
+ "message": "Remove {asset} trustline"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Insufficient funds"
+ },
+ "transactionScan.errors.noTrustline": {
+ "message": "Trustline not found"
+ },
+ "transactionScan.errors.transactionExpired": {
+ "message": "Transaction expired"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Invalid address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Unsupported method"
+ },
+ "confirmation.accountActivation.title": {
+ "message": "Activate Stellar Wallet"
+ },
+ "confirmation.accountActivation.description": {
+ "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets."
+ },
+ "confirmation.accountActivation.callToAction": {
+ "message": "Add XLM to your wallet to get started."
+ },
+ "confirmation.accountActivation.address": {
+ "message": "Stellar address"
+ },
+ "confirmation.accountActivation.copyAddress": {
+ "message": "Copy Address"
+ }
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json
new file mode 100644
index 00000000..42fd05f3
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/messages.json
@@ -0,0 +1,485 @@
+{
+ "confirmation.transaction.title": {
+ "message": "Transaction request"
+ },
+ "confirmation.estimatedChanges.title": {
+ "message": "Estimated changes"
+ },
+ "confirmation.estimatedChanges.tooltip": {
+ "message": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee."
+ },
+ "confirmation.estimatedChanges.send": {
+ "message": "You send"
+ },
+ "confirmation.estimatedChanges.receive": {
+ "message": "You receive"
+ },
+ "confirmation.origin": {
+ "message": "Request from"
+ },
+ "confirmation.origin.tooltip": {
+ "message": "This is the site asking for your confirmation."
+ },
+ "confirmation.from": {
+ "message": "From"
+ },
+ "confirmation.to": {
+ "message": "To"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Network fee"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirm"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancel"
+ },
+ "confirmation.reviewAlertsButton": {
+ "message": "Review alert"
+ },
+ "confirmation.maliciousAck.title": {
+ "message": "Malicious request"
+ },
+ "confirmation.maliciousAck.description": {
+ "message": "If you confirm this request, you will probably lose your assets to a scammer."
+ },
+ "confirmation.maliciousAck.checkbox": {
+ "message": "I have acknowledged the risk and still want to proceed"
+ },
+ "confirmation.maliciousAck.proceed": {
+ "message": "Confirm"
+ },
+ "confirmation.maliciousAck.back": {
+ "message": "Go back"
+ },
+ "confirmation.closeButton": {
+ "message": "Close"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Sign message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.signAuthEntry.title": {
+ "message": "Authorize smart contract"
+ },
+ "confirmation.signAuthEntry.warning": {
+ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site."
+ },
+ "confirmation.signAuthEntry.contract": {
+ "message": "Contract Address"
+ },
+ "confirmation.signAuthEntry.function": {
+ "message": "Function Name"
+ },
+ "confirmation.signAuthEntry.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.signAuthEntry.expiresAt": {
+ "message": "Expires at ledger"
+ },
+ "confirmation.signAuthEntry.nonce": {
+ "message": "Nonce"
+ },
+ "confirmation.signAuthEntry.subInvocations": {
+ "message": "Nested authorizations"
+ },
+ "confirmation.signAuthEntry.createContract": {
+ "message": "Deploy contract"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.memo": {
+ "message": "Memo"
+ },
+ "confirmation.asset": {
+ "message": "Asset"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Unsupported contract for simulation"
+ },
+ "confirmation.securityScanInProgressTitle": {
+ "message": "Checking for security issues"
+ },
+ "confirmation.securityScanInProgressMessage": {
+ "message": "This usually takes a few seconds."
+ },
+ "confirmation.securityScanAPIErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanAPIErrorMessage": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.securityScanErrorTitle": {
+ "message": "Security scan failed"
+ },
+ "confirmation.securityScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.securityScanIncompleteTitle": {
+ "message": "Security scan incomplete"
+ },
+ "confirmation.securityScanIncompleteSubtitle": {
+ "message": "{reason}. Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction is expected to fail."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.transactionInvalidTitle": {
+ "message": "Transaction is no longer valid"
+ },
+ "confirmation.transactionInvalidSubtitle": {
+ "message": "It may have expired or your account balance changed. Close this request and try again."
+ },
+ "confirmation.validationScanErrorTitle": {
+ "message": "Security check unavailable"
+ },
+ "confirmation.validationScanErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationWarningTitle": {
+ "message": "This request may be risky"
+ },
+ "confirmation.validationWarningSubtitle": {
+ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "See details"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Powered by"
+ },
+ "confirmation.transaction.accountmerge": {
+ "message": "Merge account"
+ },
+ "confirmation.transaction.allowtrust": {
+ "message": "Allow trust"
+ },
+ "confirmation.transaction.beginsponsoringfuturereserves": {
+ "message": "Begin sponsoring future reserves"
+ },
+ "confirmation.transaction.bumpsequence": {
+ "message": "Bump sequence"
+ },
+ "confirmation.transaction.changetrust": {
+ "message": "Change trust line"
+ },
+ "confirmation.transaction.claimclaimablebalance": {
+ "message": "Claim claimable balance"
+ },
+ "confirmation.transaction.clawback": {
+ "message": "Clawback"
+ },
+ "confirmation.transaction.clawbackclaimablebalance": {
+ "message": "Clawback claimable balance"
+ },
+ "confirmation.transaction.createaccount": {
+ "message": "Create account"
+ },
+ "confirmation.transaction.createclaimablebalance": {
+ "message": "Create claimable balance"
+ },
+ "confirmation.transaction.createpassiveselloffer": {
+ "message": "Create passive sell offer"
+ },
+ "confirmation.transaction.endsponsoringfuturereserves": {
+ "message": "End sponsoring future reserves"
+ },
+ "confirmation.transaction.extendfootprintttl": {
+ "message": "Extend footprint TTL"
+ },
+ "confirmation.transaction.inflation": {
+ "message": "Inflation"
+ },
+ "confirmation.transaction.invokehostfunction": {
+ "message": "Invoke host function"
+ },
+ "confirmation.transaction.liquiditypooldeposit": {
+ "message": "Liquidity pool deposit"
+ },
+ "confirmation.transaction.liquiditypoolwithdraw": {
+ "message": "Liquidity pool withdraw"
+ },
+ "confirmation.transaction.managedata": {
+ "message": "Manage data"
+ },
+ "confirmation.transaction.managebuyoffer": {
+ "message": "Manage buy offer"
+ },
+ "confirmation.transaction.manageselloffer": {
+ "message": "Manage sell offer"
+ },
+ "confirmation.transaction.pathpaymentstrictreceive": {
+ "message": "Path payment (strict receive)"
+ },
+ "confirmation.transaction.pathpaymentstrictsend": {
+ "message": "Path payment (strict send)"
+ },
+ "confirmation.transaction.payment": {
+ "message": "Payment"
+ },
+ "confirmation.transaction.restorefootprint": {
+ "message": "Restore footprint"
+ },
+ "confirmation.transaction.revokesponsorship": {
+ "message": "Revoke sponsorship"
+ },
+ "confirmation.transaction.setoptions": {
+ "message": "Set options"
+ },
+ "confirmation.transaction.settrustlineflags": {
+ "message": "Set trust line flags"
+ },
+ "confirmation.transaction.param.account": {
+ "message": "Account"
+ },
+ "confirmation.transaction.param.amount": {
+ "message": "Amount"
+ },
+ "confirmation.transaction.param.asset": {
+ "message": "Asset"
+ },
+ "confirmation.transaction.param.assetCode": {
+ "message": "Asset code"
+ },
+ "confirmation.transaction.param.authorize": {
+ "message": "Authorize"
+ },
+ "confirmation.transaction.param.balanceId": {
+ "message": "Balance ID"
+ },
+ "confirmation.transaction.param.buyAmount": {
+ "message": "Buy amount"
+ },
+ "confirmation.transaction.param.bumpTo": {
+ "message": "Bump to"
+ },
+ "confirmation.transaction.param.buying": {
+ "message": "Buying"
+ },
+ "confirmation.transaction.param.claimants": {
+ "message": "Claimants"
+ },
+ "confirmation.transaction.param.clearFlags": {
+ "message": "Clear flags"
+ },
+ "confirmation.transaction.param.contractId": {
+ "message": "Contract Address"
+ },
+ "confirmation.transaction.param.destAmount": {
+ "message": "Destination amount"
+ },
+ "confirmation.transaction.param.destAsset": {
+ "message": "Destination asset"
+ },
+ "confirmation.transaction.param.destMin": {
+ "message": "Destination minimum"
+ },
+ "confirmation.transaction.param.destination": {
+ "message": "Destination"
+ },
+ "confirmation.transaction.param.extendTo": {
+ "message": "Extend to"
+ },
+ "confirmation.transaction.param.flags": {
+ "message": "Flags"
+ },
+ "confirmation.transaction.param.functionName": {
+ "message": "Function"
+ },
+ "confirmation.transaction.param.arguments": {
+ "message": "Arguments"
+ },
+ "confirmation.transaction.param.argument": {
+ "message": "Argument {index}"
+ },
+ "confirmation.transaction.param.from": {
+ "message": "From"
+ },
+ "confirmation.transaction.param.highThreshold": {
+ "message": "High threshold"
+ },
+ "confirmation.transaction.param.homeDomain": {
+ "message": "Home domain"
+ },
+ "confirmation.transaction.param.hostFunctionXdrBase64": {
+ "message": "Host function (XDR, base64)"
+ },
+ "confirmation.transaction.param.inflationDest": {
+ "message": "Inflation destination"
+ },
+ "confirmation.transaction.param.limit": {
+ "message": "Limit"
+ },
+ "confirmation.transaction.param.line": {
+ "message": "Trust line"
+ },
+ "confirmation.transaction.param.liquidityPoolId": {
+ "message": "Liquidity pool ID"
+ },
+ "confirmation.transaction.param.lowThreshold": {
+ "message": "Low threshold"
+ },
+ "confirmation.transaction.param.masterWeight": {
+ "message": "Master weight"
+ },
+ "confirmation.transaction.param.maxAmountA": {
+ "message": "Max amount A"
+ },
+ "confirmation.transaction.param.maxAmountB": {
+ "message": "Max amount B"
+ },
+ "confirmation.transaction.param.maxPrice": {
+ "message": "Max price"
+ },
+ "confirmation.transaction.param.medThreshold": {
+ "message": "Medium threshold"
+ },
+ "confirmation.transaction.param.minAmountA": {
+ "message": "Min amount A"
+ },
+ "confirmation.transaction.param.minAmountB": {
+ "message": "Min amount B"
+ },
+ "confirmation.transaction.param.minPrice": {
+ "message": "Min price"
+ },
+ "confirmation.transaction.param.name": {
+ "message": "Name"
+ },
+ "confirmation.transaction.param.note": {
+ "message": "Note"
+ },
+ "confirmation.transaction.param.offerId": {
+ "message": "Offer ID"
+ },
+ "confirmation.transaction.param.path": {
+ "message": "Path"
+ },
+ "confirmation.transaction.param.price": {
+ "message": "Price"
+ },
+ "confirmation.transaction.param.seller": {
+ "message": "Seller"
+ },
+ "confirmation.transaction.param.sendAmount": {
+ "message": "Send amount"
+ },
+ "confirmation.transaction.param.sendAsset": {
+ "message": "Send asset"
+ },
+ "confirmation.transaction.param.sendMax": {
+ "message": "Send max"
+ },
+ "confirmation.transaction.param.selling": {
+ "message": "Selling"
+ },
+ "confirmation.transaction.param.setFlags": {
+ "message": "Set flags"
+ },
+ "confirmation.transaction.param.signer": {
+ "message": "Signer"
+ },
+ "confirmation.transaction.param.signerEd25519": {
+ "message": "Signer (public key)"
+ },
+ "confirmation.transaction.param.signerSha256Hash": {
+ "message": "Signer (SHA-256)"
+ },
+ "confirmation.transaction.param.signerPreAuthTx": {
+ "message": "Signer (pre-auth tx)"
+ },
+ "confirmation.transaction.param.signerSignedPayload": {
+ "message": "Signer (signed payload)"
+ },
+ "confirmation.transaction.param.signerWeight": {
+ "message": "Signer weight"
+ },
+ "confirmation.transaction.param.sponsoredId": {
+ "message": "Sponsored account"
+ },
+ "confirmation.transaction.param.startingBalance": {
+ "message": "Starting balance"
+ },
+ "confirmation.transaction.param.source": {
+ "message": "Source"
+ },
+ "confirmation.transaction.param.trustor": {
+ "message": "Trustor"
+ },
+ "confirmation.transaction.param.valueBase64": {
+ "message": "Value (base64)"
+ },
+ "confirmation.signChangeTrustOptIn.title": {
+ "message": "Add {asset} trustline"
+ },
+ "confirmation.signChangeTrustOptIn.updateTitle": {
+ "message": "Update {asset} trustline limit"
+ },
+ "confirmation.signChangeTrustOptOut.title": {
+ "message": "Remove {asset} trustline"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Insufficient funds"
+ },
+ "transactionScan.errors.noTrustline": {
+ "message": "Trustline not found"
+ },
+ "transactionScan.errors.transactionExpired": {
+ "message": "Transaction expired"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Invalid address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Unsupported method"
+ },
+ "confirmation.accountActivation.title": {
+ "message": "Activate Stellar Wallet"
+ },
+ "confirmation.accountActivation.description": {
+ "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets."
+ },
+ "confirmation.accountActivation.callToAction": {
+ "message": "Add XLM to your wallet to get started."
+ },
+ "confirmation.accountActivation.address": {
+ "message": "Stellar address"
+ },
+ "confirmation.accountActivation.copyAddress": {
+ "message": "Copy Address"
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json
new file mode 100644
index 00000000..f49ef41d
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "@metamask/stellar-wallet-snap",
+ "version": "0.0.1",
+ "description": "A Stellar wallet Snap.",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/snap-stellar-wallet.git"
+ },
+ "license": "(MIT-0 OR Apache-2.0)",
+ "main": "./dist/bundle.js",
+ "files": [
+ "dist/",
+ "images/",
+ "snap.manifest.json",
+ "locales/"
+ ],
+ "scripts": {
+ "allow-scripts": "yarn workspace root allow-scripts",
+ "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap",
+ "build:dev": "ENVIRONMENT=local node scripts/update-manifest-local.js && mm-snap build && yarn build:locale",
+ "build:prod": "ENVIRONMENT=production node scripts/update-manifest-local.js && mm-snap build && yarn build:locale && yarn build-preinstalled-snap",
+ "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js",
+ "build:clean": "yarn clean && yarn build:locale && yarn build",
+ "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w",
+ "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"",
+ "changelog:update": "../../scripts/update-changelog.sh @metamask/stellar-wallet-snap",
+ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/stellar-wallet-snap",
+ "clean": "rimraf dist",
+ "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types",
+ "lint:deps": "depcheck && yarn dedupe --check",
+ "lint:deps:fix": "depcheck && yarn dedupe",
+ "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx",
+ "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write",
+ "lint:misc": "prettier '**/*.json' '**/*.md' --check",
+ "lint:types": "tsc --noEmit",
+ "format": "prettier '**/*.ts' '**/*.tsx' --write",
+ "prepublishOnly": "mm-snap manifest",
+ "serve": "mm-snap serve",
+ "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"",
+ "test": "jest --passWithNoTests --coverage=false",
+ "test:coverage": "jest --passWithNoTests --coverage=true && yarn jest-it-up",
+ "test:integration": "./integration-test/run-integration.sh"
+ },
+ "devDependencies": {
+ "@metamask/auto-changelog": "^6.1.0",
+ "@metamask/key-tree": "^10.1.1",
+ "@metamask/keyring-api": "^23.7.0",
+ "@metamask/keyring-snap-sdk": "^9.2.1",
+ "@metamask/snaps-cli": "^8.4.0",
+ "@metamask/snaps-jest": "^10.1.0",
+ "@metamask/snaps-sdk": "^11.1.0",
+ "@metamask/superstruct": "^3.2.1",
+ "@metamask/utils": "^11.11.0",
+ "@stellar/stellar-sdk": "^15.0.1",
+ "@types/jest": "^30.0.0",
+ "async-mutex": "^0.5.0",
+ "bignumber.js": "^9.3.1",
+ "concurrently": "^10.0.3",
+ "dotenv": "^17.2.1",
+ "jest": "^30.0.3",
+ "jest-it-up": "^2.0.2",
+ "jest-transform-stub": "2.0.0",
+ "lodash": "^4.18.1",
+ "prettier": "^3.5.3",
+ "qrcode-generator": "^2.0.4",
+ "ts-jest": "^29.4.11"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js
new file mode 100644
index 00000000..cb4517eb
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js
@@ -0,0 +1,76 @@
+// @ts-check
+
+const { readFileSync, writeFileSync } = require('node:fs');
+const { join } = require('node:path');
+
+const packageFile = require('../package.json');
+
+console.log('[preinstalled-snap] - attempt to build preinstalled snap');
+
+/**
+ * Read the contents of a file and return as a string.
+ * @param {string} filePath - Path to file.
+ * @returns {string} File as utf-8 string.
+ */
+function readFileContents(filePath) {
+ try {
+ return readFileSync(filePath, 'utf8');
+ } catch (error) {
+ console.error(`Error reading file from disk: ${filePath}`, error);
+ throw error;
+ }
+}
+
+// Paths to the files
+const bundlePath = require.resolve('../dist/bundle.js');
+const iconPath = require.resolve('../images/icon.svg');
+const manifestPath = require.resolve('../snap.manifest.json');
+const englishLocalePath = require.resolve('../locales/en.json');
+
+// File Contents
+const bundle = readFileContents(bundlePath);
+const icon = readFileContents(iconPath);
+const manifest = readFileContents(manifestPath);
+const englishLocale = readFileContents(englishLocalePath);
+
+const snapId =
+ /** @type {import('@metamask/snaps-controllers').PreinstalledSnap['snapId']} */ (
+ `npm:${packageFile.name}`
+ );
+
+/**
+ * @type {import('@metamask/snaps-controllers').PreinstalledSnap}
+ */
+const preinstalledSnap = {
+ snapId,
+ manifest: JSON.parse(manifest),
+ files: [
+ {
+ path: 'images/icon.svg',
+ value: icon,
+ },
+ {
+ path: 'dist/bundle.js',
+ value: bundle,
+ },
+ {
+ path: 'locales/en.json',
+ value: englishLocale,
+ },
+ ],
+ removable: false,
+ hideSnapBranding: true,
+};
+
+// Write preinstalled-snap file
+try {
+ const outputPath = join(__dirname, '..', 'dist/preinstalled-snap.json');
+ writeFileSync(outputPath, JSON.stringify(preinstalledSnap, null, 0));
+
+ console.log(
+ `[preinstalled-snap] - successfully created preinstalled snap at ${outputPath}`,
+ );
+} catch (error) {
+ console.error('Error writing combined file to disk:', error);
+ throw error;
+}
diff --git a/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js b/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js
new file mode 100644
index 00000000..e3f1ee6c
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js
@@ -0,0 +1,26 @@
+const { writeFileSync } = require('node:fs');
+const { join } = require('node:path');
+
+const messages = require('../messages.json');
+
+console.log('[populate-en-locale] - attempt to populate en locale');
+
+const englishLocale = {
+ locale: 'en',
+ messages: Object.entries(messages).reduce((acc, [key, { message }]) => {
+ acc[key] = { message };
+ return acc;
+ }, {}),
+};
+
+// Write en locale file
+try {
+ writeFileSync(
+ join(__dirname, '../locales/en.json'),
+ JSON.stringify(englishLocale, null, 2),
+ );
+ console.log('[populate-en-locale] - en locale populated');
+} catch (error) {
+ console.error('Error writing en locale file', error);
+ throw error;
+}
diff --git a/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js b/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js
new file mode 100644
index 00000000..0bb2fbbb
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js
@@ -0,0 +1,56 @@
+const fs = require('fs');
+const path = require('path');
+require('dotenv').config();
+
+const manifestPath = path.join(__dirname, '..', 'snap.manifest.json');
+const environment = process.env.ENVIRONMENT || 'local';
+const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+
+if (environment === 'local' || environment === 'test') {
+ manifest.initialConnections['http://localhost:3000'] = {};
+ if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) {
+ if (!manifest.initialPermissions['endowment:keyring'].allowedOrigins.includes('http://localhost:3000')) {
+ manifest.initialPermissions['endowment:keyring'].allowedOrigins.push('http://localhost:3000');
+ }
+ }
+
+ // Add endowment:rpc permission for local/dev mode
+ manifest.initialPermissions['endowment:rpc'] = {
+ dapps: true,
+ snaps: false
+ };
+
+ console.log('Added localhost entries and endowment:rpc to snap.manifest.json for local development');
+} else {
+ // Production mode - remove local-only settings
+ let changed = false;
+
+ // Remove localhost from initialConnections
+ if (manifest.initialConnections?.['http://localhost:3000']) {
+ delete manifest.initialConnections['http://localhost:3000'];
+ changed = true;
+ }
+
+ // Remove localhost from keyring allowedOrigins
+ if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) {
+ const origins = manifest.initialPermissions['endowment:keyring'].allowedOrigins;
+ const index = origins.indexOf('http://localhost:3000');
+ if (index > -1) {
+ origins.splice(index, 1);
+ changed = true;
+ }
+ }
+
+ // Remove endowment:rpc permission
+ if (manifest.initialPermissions?.['endowment:rpc']) {
+ delete manifest.initialPermissions['endowment:rpc'];
+ changed = true;
+ }
+
+ if (changed) {
+ console.log('Removed local-only settings from snap.manifest.json for production');
+ }
+}
+
+fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
+
diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts
new file mode 100644
index 00000000..ada6f993
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/snap.config.ts
@@ -0,0 +1,52 @@
+import type { SnapConfig } from '@metamask/snaps-cli';
+import { config as dotenv } from 'dotenv';
+import { resolve } from 'path';
+
+dotenv();
+
+const config: SnapConfig = {
+ input: resolve(__dirname, 'src/index.ts'),
+ server: {
+ port: 8080,
+ },
+ environment: {
+ ENVIRONMENT: process.env.ENVIRONMENT ?? '',
+ LOG_LEVEL: process.env.LOG_LEVEL ?? '',
+ RPC_URL_MAINNET: process.env.RPC_URL_MAINNET ?? '',
+ HORIZON_URL_MAINNET: process.env.HORIZON_URL_MAINNET ?? '',
+ EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL ?? '',
+ RPC_URL_TESTNET: process.env.RPC_URL_TESTNET ?? '',
+ HORIZON_URL_TESTNET: process.env.HORIZON_URL_TESTNET ?? '',
+ EXPLORER_TESTNET_BASE_URL: process.env.EXPLORER_TESTNET_BASE_URL ?? '',
+ TRANSACTION_TIMEOUT: process.env.TRANSACTION_TIMEOUT ?? '',
+ TRANSACTION_POLLING_ATTEMPTS:
+ process.env.TRANSACTION_POLLING_ATTEMPTS ?? '',
+ TRACK_TRANSACTION_MAX_RESCHEDULES:
+ process.env.TRACK_TRANSACTION_MAX_RESCHEDULES ?? '',
+ TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '',
+ STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '',
+ PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '',
+ SECURITY_ALERTS_API_BASE_URL:
+ process.env.SECURITY_ALERTS_API_BASE_URL ?? '',
+ FIAT_EXCHANGE_RATES_TTL_MILLISECONDS:
+ process.env.FIAT_EXCHANGE_RATES_TTL_MILLISECONDS ?? '',
+ HISTORICAL_PRICES_TTL_MILLISECONDS:
+ process.env.HISTORICAL_PRICES_TTL_MILLISECONDS ?? '',
+ SPOT_PRICES_TTL_MILLISECONDS:
+ process.env.SPOT_PRICES_TTL_MILLISECONDS ?? '',
+ BASE_FEE_TTL_MILLISECONDS: process.env.BASE_FEE_TTL_MILLISECONDS ?? '',
+ LOAD_ON_CHAIN_ACCOUNT_TTL_MILLISECONDS:
+ process.env.LOAD_ON_CHAIN_ACCOUNT_TTL_MILLISECONDS ?? '',
+ SIMULATE_TRANSACTION_TTL_MILLISECONDS:
+ process.env.SIMULATE_TRANSACTION_TTL_MILLISECONDS ?? '',
+ SEP41_ASSET_BALANCE_TTL_MILLISECONDS:
+ process.env.SEP41_ASSET_BALANCE_TTL_MILLISECONDS ?? '',
+ BASE_FEE_MULTIPLIER: process.env.BASE_FEE_MULTIPLIER ?? '',
+ MAX_FEE_THRESHOLD_IN_XLM: process.env.MAX_FEE_THRESHOLD_IN_XLM ?? '',
+ MAX_RECONCILE_ATTEMPTS: process.env.MAX_RECONCILE_ATTEMPTS ?? '',
+ MAX_PENDING_TRANSACTION_AGE: process.env.MAX_PENDING_TRANSACTION_AGE ?? '',
+ },
+ polyfills: true,
+};
+
+export default config;
diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json
new file mode 100644
index 00000000..97b74c41
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/snap.manifest.json
@@ -0,0 +1,61 @@
+{
+ "version": "0.0.1",
+ "description": "Manage Stellar using MetaMask",
+ "proposedName": "Stellar",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/snap-stellar-wallet.git"
+ },
+ "source": {
+ "shasum": "1ftcq5ewpbXIRZpVifhbpo4kHtdvXB4MEoo+bgjMWGQ=",
+ "location": {
+ "npm": {
+ "filePath": "dist/bundle.js",
+ "iconPath": "images/icon.svg",
+ "packageName": "@metamask/stellar-wallet-snap",
+ "registry": "https://registry.npmjs.org/"
+ }
+ },
+ "locales": ["locales/en.json"]
+ },
+ "initialConnections": {
+ "https://portfolio.metamask.io": {}
+ },
+ "initialPermissions": {
+ "endowment:keyring": {
+ "allowedOrigins": ["https://portfolio.metamask.io"]
+ },
+ "snap_getBip32Entropy": [
+ {
+ "path": ["m", "44'", "148'"],
+ "curve": "ed25519"
+ }
+ ],
+ "endowment:network-access": {},
+ "snap_manageAccounts": {},
+ "snap_manageState": {},
+ "snap_dialog": {},
+ "snap_getPreferences": {},
+ "endowment:cronjob": {
+ "jobs": [
+ {
+ "duration": "PT30S",
+ "request": {
+ "method": "synchronizeAccounts"
+ }
+ },
+ {
+ "duration": "PT1H",
+ "request": {
+ "method": "synchronizeAssets"
+ }
+ }
+ ]
+ },
+ "endowment:assets": {
+ "scopes": ["stellar:pubnet"]
+ }
+ },
+ "platformVersion": "11.1.1",
+ "manifestVersion": "0.1"
+}
diff --git a/merged-packages/stellar-wallet-snap/src/api/address.test.ts b/merged-packages/stellar-wallet-snap/src/api/address.test.ts
new file mode 100644
index 00000000..377a4b06
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/address.test.ts
@@ -0,0 +1,48 @@
+import { assert, StructError } from '@metamask/superstruct';
+
+import {
+ StellarAddressOrContractStruct,
+ StellarAddressStruct,
+} from './address';
+
+describe('StellarAddressStruct', () => {
+ it('accepts a valid Stellar address', () => {
+ expect(() =>
+ assert(
+ 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO',
+ StellarAddressStruct,
+ ),
+ ).not.toThrow();
+ });
+
+ it('rejects an invalid Stellar address', () => {
+ const address = 'invalid-address';
+ expect(() => assert(address, StellarAddressStruct)).toThrow(StructError);
+ });
+});
+
+describe('StellarAddressOrContractStruct', () => {
+ it('accepts a valid Stellar address', () => {
+ expect(() =>
+ assert(
+ 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO',
+ StellarAddressOrContractStruct,
+ ),
+ ).not.toThrow();
+ });
+
+ it('accepts a valid Stellar contract', () => {
+ expect(() =>
+ assert(
+ 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT',
+ StellarAddressOrContractStruct,
+ ),
+ ).not.toThrow();
+ });
+
+ it('rejects an invalid Stellar address or contract', () => {
+ expect(() =>
+ assert('invalid-address', StellarAddressOrContractStruct),
+ ).toThrow(StructError);
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/address.ts b/merged-packages/stellar-wallet-snap/src/api/address.ts
new file mode 100644
index 00000000..08199f08
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/address.ts
@@ -0,0 +1,51 @@
+import { refine, string, nonempty } from '@metamask/superstruct';
+import type { Infer } from '@metamask/superstruct';
+import { StrKey } from '@stellar/stellar-sdk';
+
+/**
+ * Validation struct for Stellar address: must be a string matching the Stellar address format and checksum.
+ * We only support non-muxed addresses.
+ */
+export const StellarAddressStruct = refine(
+ nonempty(string()),
+ 'stellar_address',
+ (value: string) => {
+ try {
+ if (!StrKey.isValidEd25519PublicKey(value)) {
+ return 'Invalid Stellar address';
+ }
+ return true;
+ } catch {
+ return 'Invalid Stellar address';
+ }
+ },
+);
+
+export const StellarAddressOrContractStruct = refine(
+ nonempty(string()),
+ 'stellar_contract_or_address',
+ (value: string) => {
+ try {
+ if (
+ !StrKey.isValidContract(value) &&
+ !StrKey.isValidEd25519PublicKey(value)
+ ) {
+ return 'Invalid Stellar address or contract';
+ }
+ return true;
+ } catch {
+ return 'Invalid Stellar address or contract';
+ }
+ },
+);
+/**
+ * Type for a Stellar address.
+ */
+export type StellarAddress = Infer;
+
+/**
+ * Type for a Stellar address or contract.
+ */
+export type StellarAddressOrContract = Infer<
+ typeof StellarAddressOrContractStruct
+>;
diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.test.ts b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts
new file mode 100644
index 00000000..4e39056f
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts
@@ -0,0 +1,76 @@
+import { assert, StructError } from '@metamask/superstruct';
+
+import {
+ FiatCaipAssetStruct,
+ KnownCaip19ClassicAssetStruct,
+ KnownCaip19Sep41AssetStruct,
+ KnownCaip19Slip44IdStruct,
+} from './asset';
+
+describe('KnownCaip19ClassicAssetStruct', () => {
+ it('accepts a valid CAIP-19 asset', () => {
+ expect(() =>
+ assert(
+ 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ KnownCaip19ClassicAssetStruct,
+ ),
+ ).not.toThrow();
+ });
+
+ it('rejects an invalid CAIP-19 asset', () => {
+ const address = 'invalid-caip19-asset';
+ expect(() => assert(address, KnownCaip19ClassicAssetStruct)).toThrow(
+ StructError,
+ );
+ });
+});
+
+describe('KnownCaip19Sep41AssetStruct', () => {
+ it.each([
+ 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J',
+ 'stellar:pubnet/sep41:CBIJBDNZNF4X35BJ4FFZWCDBSCKOP5NB4PLG4SNENRMLAPYG4P5FM6VN',
+ ])('accepts a valid CAIP-19 asset', (assetId) => {
+ expect(() => assert(assetId, KnownCaip19Sep41AssetStruct)).not.toThrow();
+ });
+
+ it('rejects an invalid CAIP-19 asset', () => {
+ const address = 'invalid-caip19-asset';
+ expect(() => assert(address, KnownCaip19Sep41AssetStruct)).toThrow(
+ StructError,
+ );
+ });
+});
+
+describe('FiatCaipAssetStruct', () => {
+ it.each(['swift:0/iso4217:USD', 'swift:0/iso4217:eur'])(
+ 'accepts a valid fiat CAIP-19 asset id',
+ (assetId) => {
+ expect(() => assert(assetId, FiatCaipAssetStruct)).not.toThrow();
+ },
+ );
+
+ it.each([
+ 'stellar:pubnet/slip44:148',
+ 'eip155:1/swift:0/iso4217:USD',
+ 'swift:0/iso4217:US',
+ 'swift:0/iso4217:USDC',
+ 'eip155:1/notswift:0/iso4217:USD',
+ ])('rejects a non-fiat CAIP-19 asset id', (assetId) => {
+ expect(() => assert(assetId, FiatCaipAssetStruct)).toThrow(StructError);
+ });
+});
+
+describe('KnownCaip19Slip44IdStruct', () => {
+ it('accepts a valid CAIP-19 asset', () => {
+ expect(() =>
+ assert('stellar:pubnet/slip44:148', KnownCaip19Slip44IdStruct),
+ ).not.toThrow();
+ });
+
+ it('rejects an invalid CAIP-19 asset', () => {
+ const address = 'invalid-caip19-asset';
+ expect(() => assert(address, KnownCaip19Slip44IdStruct)).toThrow(
+ StructError,
+ );
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.ts b/merged-packages/stellar-wallet-snap/src/api/asset.ts
new file mode 100644
index 00000000..99612c80
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/asset.ts
@@ -0,0 +1,88 @@
+import type { Infer } from '@metamask/superstruct';
+import { union } from '@metamask/superstruct';
+import type { CaipAssetType } from '@metamask/utils';
+import { definePattern } from '@metamask/utils';
+
+import { KnownCaip2ChainId } from './network';
+import { STELLAR_COIN_TYPE } from '../constants';
+
+/** Stellar Asset namespace */
+/** Please see https://namespaces.chainagnostic.org/stellar/caip19#asset-namespaces */
+export enum AssetType {
+ Native = 'slip44',
+ Token = 'asset',
+ Sep41 = 'sep41',
+}
+
+/** Known CAIP-19 IDs */
+export const KnownCaip19Slip44IdStruct =
+ definePattern<`${KnownCaip2ChainId}/${AssetType.Native}:${typeof STELLAR_COIN_TYPE}`>(
+ 'KnownCaip19Slip44Id',
+ /^stellar:(?:pubnet|testnet)\/slip44:148$/u,
+ );
+
+export const KnownCaip19Slip44IdMap: Record<
+ KnownCaip2ChainId,
+ KnownCaip19Slip44Id
+> = {
+ [KnownCaip2ChainId.Mainnet]: `${KnownCaip2ChainId.Mainnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`,
+ [KnownCaip2ChainId.Testnet]: `${KnownCaip2ChainId.Testnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`,
+};
+
+/**
+ * CAIP-19 token asset ID: {chainId}/asset:{assetCode}-{issuerAddress}
+ *
+ * @see https://namespaces.chainagnostic.org/stellar/caip19#asset-namespaces
+ */
+export const KnownCaip19ClassicAssetStruct =
+ definePattern<`${KnownCaip2ChainId}/${AssetType.Token}:${string}-${string}`>(
+ 'KnownCaip19ClassicAsset',
+ /^stellar:(?:pubnet|testnet)\/asset:[A-Za-z0-9]{1,12}-G[A-Z2-7]{55}$/u,
+ );
+
+export const KnownCaip19Sep41AssetStruct =
+ definePattern<`${KnownCaip2ChainId}/${AssetType.Sep41}:${string}`>(
+ 'KnownCaip19Sep41Asset',
+ /^stellar:(?:pubnet|testnet)\/sep41:C[A-Z2-7]{55}$/u,
+ );
+
+/**
+ * Fiat asset id in SWIFT / ISO 4217 form only: `swift:0/iso4217:{code}` (3-letter code).
+ *
+ * @see https://github.com/MetaMask/core/blob/main/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts#L44
+ */
+export const FiatCaipAssetStruct = definePattern(
+ 'FiatCaipAsset',
+ /^swift:0\/iso4217:[A-Za-z]{3}$/u,
+);
+
+/** Validation struct for a known Stellar CAIP-19 asset id or slip44 id. */
+export const KnownCaip19AssetIdOrSlip44IdStruct = union([
+ KnownCaip19Sep41AssetStruct,
+ KnownCaip19ClassicAssetStruct,
+ KnownCaip19Slip44IdStruct,
+]);
+
+/** Fiat CAIP-19 asset id (SWIFT / ISO 4217). */
+export type FiatCaipAssetId = Infer;
+
+/** CAIP-19 Sep41 asset ID */
+export type KnownCaip19Sep41AssetId = Infer;
+
+/** CAIP-19 Classic asset ID */
+export type KnownCaip19ClassicAssetId = Infer<
+ typeof KnownCaip19ClassicAssetStruct
+>;
+
+/** CAIP-19 slip44 ID */
+export type KnownCaip19Slip44Id = Infer;
+
+/** CAIP-19 asset ID */
+export type KnownCaip19AssetId =
+ | KnownCaip19Sep41AssetId
+ | KnownCaip19ClassicAssetId;
+
+/** CAIP-19 asset ID or slip44 ID */
+export type KnownCaip19AssetIdOrSlip44Id = Infer<
+ typeof KnownCaip19AssetIdOrSlip44IdStruct
+>;
diff --git a/merged-packages/stellar-wallet-snap/src/api/environment.ts b/merged-packages/stellar-wallet-snap/src/api/environment.ts
new file mode 100644
index 00000000..1b6b0511
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/environment.ts
@@ -0,0 +1,5 @@
+export enum Environment {
+ Local = 'local',
+ Test = 'test',
+ Production = 'production',
+}
diff --git a/merged-packages/stellar-wallet-snap/src/api/index.ts b/merged-packages/stellar-wallet-snap/src/api/index.ts
new file mode 100644
index 00000000..3502cc99
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/index.ts
@@ -0,0 +1,11 @@
+export * from './network';
+export * from './asset';
+export * from './environment';
+export * from './loglevel';
+export * from './url';
+export * from './uuid';
+export * from './address';
+export * from './json';
+export * from './integer';
+export * from './xdr';
+export * from './transactionHash';
diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts
new file mode 100644
index 00000000..d10f155a
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts
@@ -0,0 +1,70 @@
+import { assert, StructError } from '@metamask/superstruct';
+
+import {
+ NonZeroValidStellarAmountStruct,
+ ValidStellarAmountStruct,
+ ValidAmountStruct,
+} from './integer';
+
+describe('ValidAmountStruct', () => {
+ it.each([
+ // MAX_INT64 stroops converted to XLM-style amount.
+ '922337203685.4775807',
+ '123.1212331321231',
+ '0.1',
+ '0',
+ '0.000000023',
+ ])('accepts a valid amount', (value: string) => {
+ expect(() => assert(value, ValidAmountStruct)).not.toThrow();
+ });
+
+ it('rejects a negative amount', () => {
+ expect(() => assert('-0.1', ValidAmountStruct)).toThrow(StructError);
+ });
+
+ it('rejects non-finite numeric values', () => {
+ expect(() => assert('Infinity', ValidAmountStruct)).toThrow(StructError);
+ expect(() => assert('NaN', ValidAmountStruct)).toThrow(StructError);
+ });
+});
+
+describe('ValidStellarAmountStruct', () => {
+ it('accepts a valid amount with up to 7 decimal places', () => {
+ expect(() => assert('12.3456789', ValidStellarAmountStruct)).not.toThrow();
+ });
+
+ it('accepts an amount just below max int64', () => {
+ expect(() =>
+ assert('922337203685.4775807', ValidStellarAmountStruct),
+ ).not.toThrow();
+ });
+
+ it('rejects an amount above max int64 when converted to stroops', () => {
+ expect(() =>
+ assert('922337203685.4775808', ValidStellarAmountStruct),
+ ).toThrow(StructError);
+ });
+
+ it('rejects an amount with more than 7 decimal places', () => {
+ expect(() => assert('1.00000001', ValidStellarAmountStruct)).toThrow(
+ StructError,
+ );
+ });
+});
+
+describe('NonZeroValidStellarAmountStruct', () => {
+ it('accepts a valid non-zero amount', () => {
+ expect(() =>
+ assert('0.0000001', NonZeroValidStellarAmountStruct),
+ ).not.toThrow();
+ });
+
+ it('rejects zero', () => {
+ expect(() => assert('0', NonZeroValidStellarAmountStruct)).toThrow(
+ StructError,
+ );
+ expect(() => assert('0.0000000', NonZeroValidStellarAmountStruct)).toThrow(
+ StructError,
+ );
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.ts b/merged-packages/stellar-wallet-snap/src/api/integer.ts
new file mode 100644
index 00000000..9e489a53
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/integer.ts
@@ -0,0 +1,78 @@
+import { nonempty, refine, string } from '@metamask/superstruct';
+import type { Infer } from '@metamask/superstruct';
+import { BigNumber } from 'bignumber.js';
+
+import { MAX_INT64, STELLAR_DECIMAL_PLACES } from '../constants';
+import { toSmallestUnit } from '../utils/currency';
+
+/**
+ * Non-empty string that parses to a finite, non-negative {@link BigNumber}.
+ */
+export const ValidAmountStruct = refine(
+ nonempty(string()),
+ 'valid_amount',
+ (value: string) => {
+ try {
+ const amount = new BigNumber(value);
+ if (
+ // < 0
+ amount.isNegative() ||
+ // NaN or Infinity
+ amount.isNaN() ||
+ !amount.isFinite()
+ ) {
+ return 'Invalid amount';
+ }
+ return true;
+ } catch {
+ return 'Invalid amount';
+ }
+ },
+);
+
+/**
+ * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not above the maximum int64.
+ * The amount is converted to the smallest unit of the asset and validated against the maximum int64.
+ */
+export const ValidStellarAmountStruct = refine(
+ ValidAmountStruct,
+ 'valid_stellar_amount',
+ (value: string) => {
+ try {
+ const amount = new BigNumber(value);
+ const decimalPlaces = amount.decimalPlaces();
+ if (
+ (decimalPlaces && decimalPlaces > STELLAR_DECIMAL_PLACES) ||
+ // > Max value
+ toSmallestUnit(amount).gt(new BigNumber(MAX_INT64).toString())
+ ) {
+ return 'Invalid amount';
+ }
+ return true;
+ } catch {
+ return 'Invalid amount';
+ }
+ },
+);
+
+/**
+ * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not zero.
+ * The amount is converted to the smallest unit of the asset and validated against the maximum int64.
+ */
+export const NonZeroValidStellarAmountStruct = refine(
+ ValidStellarAmountStruct,
+ 'non_zero_valid_amount',
+ (value: string) => {
+ const amount = new BigNumber(value);
+ if (amount.isZero()) {
+ return 'Amount cannot be zero';
+ }
+ return true;
+ },
+);
+
+export type NonZeroValidAmount = Infer;
+
+export type ValidAmount = Infer;
+
+export type ValidStellarAmount = Infer;
diff --git a/merged-packages/stellar-wallet-snap/src/api/json.ts b/merged-packages/stellar-wallet-snap/src/api/json.ts
new file mode 100644
index 00000000..535416dc
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/json.ts
@@ -0,0 +1,18 @@
+import {
+ literal,
+ object,
+ string,
+ union,
+ number,
+ optional,
+} from '@metamask/superstruct';
+
+/**
+ * Validation struct for the JSON-RPC request.
+ */
+export const JsonRpcRequestStruct = object({
+ jsonrpc: literal('2.0'),
+ id: union([string(), number(), literal(null)] as const),
+ method: string(),
+ params: optional(object()),
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/loglevel.test.ts b/merged-packages/stellar-wallet-snap/src/api/loglevel.test.ts
new file mode 100644
index 00000000..27ce3fb1
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/loglevel.test.ts
@@ -0,0 +1,24 @@
+import { assert, StructError, create } from '@metamask/superstruct';
+
+import { LogLevel, LogLevelStruct } from './loglevel';
+
+describe('LogLevelStruct', () => {
+ it.each(Object.values(LogLevel))(
+ 'accepts valid log level: %s',
+ (logLevel) => {
+ expect(() => assert(logLevel, LogLevelStruct)).not.toThrow();
+ },
+ );
+
+ it('rejects an invalid log level', () => {
+ const logLevel = 'invalid-log-level';
+ expect(() => assert(logLevel, LogLevelStruct)).toThrow(StructError);
+ });
+
+ it('returns the default log level if the log level is not provided', () => {
+ const logLevel = undefined;
+ const result = create(logLevel, LogLevelStruct);
+
+ expect(result).toStrictEqual(LogLevel.ERROR);
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/loglevel.ts b/merged-packages/stellar-wallet-snap/src/api/loglevel.ts
new file mode 100644
index 00000000..5e0d8fc0
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/loglevel.ts
@@ -0,0 +1,21 @@
+import { coerce, defaulted, enums, string } from '@metamask/superstruct';
+
+export enum LogLevel {
+ ALL = 'all',
+ ERROR = 'error',
+ WARN = 'warn',
+ INFO = 'info',
+ DEBUG = 'debug',
+ SILENT = 'silent',
+}
+
+/**
+ * A struct to validate and coerce log level from env.
+ * Converts the log level to lowercase and checks if it is a valid log level.
+ * If the log level is empty, it returns the default log level.
+ */
+export const LogLevelStruct = coerce(
+ defaulted(enums(Object.values(LogLevel)), LogLevel.ERROR),
+ string(),
+ (value: string) => (value === '' ? undefined : value.toLowerCase()),
+);
diff --git a/merged-packages/stellar-wallet-snap/src/api/network.test.ts b/merged-packages/stellar-wallet-snap/src/api/network.test.ts
new file mode 100644
index 00000000..6be94246
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/network.test.ts
@@ -0,0 +1,17 @@
+import { assert, StructError } from '@metamask/superstruct';
+
+import { KnownCaip2ChainId, KnownCaip2ChainIdStruct } from './network';
+
+describe('KnownCaip2ChainIdStruct', () => {
+ it.each([KnownCaip2ChainId.Mainnet, KnownCaip2ChainId.Testnet])(
+ 'accepts valid chain ID',
+ (chainId) => {
+ expect(() => assert(chainId, KnownCaip2ChainIdStruct)).not.toThrow();
+ },
+ );
+
+ it('rejects an invalid chain ID', () => {
+ const chainId = 'invalid-chain-id';
+ expect(() => assert(chainId, KnownCaip2ChainIdStruct)).toThrow(StructError);
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/network.ts b/merged-packages/stellar-wallet-snap/src/api/network.ts
new file mode 100644
index 00000000..c7dc201c
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/network.ts
@@ -0,0 +1,13 @@
+/** Stellar Chain namespace */
+
+import { enums } from '@metamask/superstruct';
+import { KnownCaipNamespace } from '@metamask/utils';
+
+/** Known CAIP-2 IDs */
+/** Please see https://namespaces.chainagnostic.org/stellar/caip2 */
+export enum KnownCaip2ChainId {
+ Mainnet = `${KnownCaipNamespace.Stellar}:pubnet`,
+ Testnet = `${KnownCaipNamespace.Stellar}:testnet`,
+}
+
+export const KnownCaip2ChainIdStruct = enums(Object.values(KnownCaip2ChainId));
diff --git a/merged-packages/stellar-wallet-snap/src/api/string.ts b/merged-packages/stellar-wallet-snap/src/api/string.ts
new file mode 100644
index 00000000..868ed4f2
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/string.ts
@@ -0,0 +1,18 @@
+import type { Infer } from '@metamask/superstruct';
+import { refine, string } from '@metamask/superstruct';
+
+/**
+ * Validation struct for a UTF-8 string.
+ */
+export const Utf8StringStruct = refine(string(), 'utf8', (value) => {
+ try {
+ // Attempt to encode to UTF-8
+ const encoder = new TextEncoder();
+ encoder.encode(value);
+ return true; // Valid UTF-8
+ } catch {
+ return 'Invalid UTF-8 string';
+ }
+});
+
+export type Utf8String = Infer;
diff --git a/merged-packages/stellar-wallet-snap/src/api/transactionHash.test.ts b/merged-packages/stellar-wallet-snap/src/api/transactionHash.test.ts
new file mode 100644
index 00000000..3a3fcc17
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/transactionHash.test.ts
@@ -0,0 +1,26 @@
+import { assert, StructError } from '@metamask/superstruct';
+
+import { StellarTransactionHashStruct } from './transactionHash';
+
+describe('StellarTransactionHashStruct', () => {
+ it.each([
+ '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1',
+ '7D4B0C5EF7498B223F45A10F461060FB64F53EB13CAF18E8DC7DE95A8CF9C0E1',
+ ])('accepts a valid Stellar transaction hash: %s', (transactionHash) => {
+ expect(() =>
+ assert(transactionHash, StellarTransactionHashStruct),
+ ).not.toThrow();
+ });
+
+ it.each([
+ '',
+ 'dGVzdA==',
+ 'not-a-transaction-hash',
+ '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0',
+ '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0eg',
+ ])('rejects an invalid Stellar transaction hash: %s', (transactionHash) => {
+ expect(() => assert(transactionHash, StellarTransactionHashStruct)).toThrow(
+ StructError,
+ );
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts b/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts
new file mode 100644
index 00000000..b567253f
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts
@@ -0,0 +1,12 @@
+import type { Infer } from '@metamask/superstruct';
+import { definePattern } from '@metamask/utils';
+
+/**
+ * Validation struct for Stellar transaction hashes returned by RPC.
+ */
+export const StellarTransactionHashStruct = definePattern(
+ 'StellarTransactionHash',
+ /^[0-9a-f]{64}$/iu,
+);
+
+export type TransactionId = Infer;
diff --git a/merged-packages/stellar-wallet-snap/src/api/url.test.ts b/merged-packages/stellar-wallet-snap/src/api/url.test.ts
new file mode 100644
index 00000000..cdb12adf
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/api/url.test.ts
@@ -0,0 +1,130 @@
+/* eslint-disable jest/expect-expect -- assertions are in assertValid/assertInvalid helpers */
+import { assert } from '@metamask/superstruct';
+
+import { UrlStruct } from './url';
+
+const assertValid = (value: string) => {
+ expect(() => assert(value, UrlStruct)).not.toThrow();
+};
+
+const assertInvalid = (value: string, expectedMessage?: string) => {
+ try {
+ assert(value, UrlStruct);
+ throw new Error('Expected assertion to throw');
+ } catch (thrown) {
+ const error = thrown as Error;
+ expect(error).toBeDefined();
+ if (expectedMessage !== undefined) {
+ expect(error.message).toContain(expectedMessage);
+ }
+ }
+};
+
+describe('UrlStruct', () => {
+ it.each([
+ // https URL with domain
+ 'https://example.com',
+ // https URL with path
+ 'https://example.com/path/to/resource',
+ // https URL with query string
+ 'https://example.com/api?foo=bar',
+ // http URL
+ 'http://example.com',
+ // wss URL
+ 'wss://example.com/socket',
+ // localhost without port
+ 'http://localhost',
+ // localhost with port
+ 'http://localhost:3000',
+ // https URL with domain
+ 'https://api.example.com',
+ ])('accepts %s', (url) => {
+ assertValid(url);
+ });
+
+ describe('protocol validation', () => {
+ it.each([
+ // ftp protocol
+ 'ftp://example.com',
+ // file protocol
+ 'file:///etc/passwd',
+ // javascript protocol
+ // eslint-disable-next-line no-script-url
+ 'javascript:alert(1)',
+ // file protocol
+ 'file:///etc/passwd',
+ // data URI
+ 'data:text/html,',
+ callback: 'javascript:alert(1)',
+ },
+ }),
+ ).toThrow('URL contains potentially malicious patterns');
+ });
+
+ it('prevents path traversal attacks', () => {
+ const result = buildUrl({
+ baseUrl: 'https://api.example.com',
+ path: '/../../../etc/passwd',
+ queryParams: {},
+ });
+ expect(result).toBe('https://api.example.com/etc/passwd');
+ });
+
+ it('handles null and undefined query parameters', () => {
+ const result = buildUrl({
+ baseUrl: 'https://api.example.com',
+ path: '/users',
+ queryParams: {
+ id: null as unknown as string,
+ name: undefined as unknown as string,
+ valid: 'data',
+ },
+ });
+ expect(result).toBe('https://api.example.com/users?valid=data');
+ });
+
+ it('prevents protocol switching in parameters', () => {
+ expect(() =>
+ buildUrl({
+ baseUrl: 'https://api.example.com',
+ path: '/redirect',
+ queryParams: {
+ url: 'javascript://alert(1)',
+ next: 'data:text/html,',
+ },
+ }),
+ ).toThrow('URL contains potentially malicious patterns');
+ });
+
+ it('handles empty path segments', () => {
+ const result = buildUrl({
+ baseUrl: 'https://api.example.com',
+ path: '//path//to//resource//',
+ queryParams: {},
+ });
+ expect(result).toBe('https://api.example.com/path/to/resource');
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts b/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts
new file mode 100644
index 00000000..1698f5d6
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts
@@ -0,0 +1,78 @@
+import { assert } from '@metamask/superstruct';
+
+import { sanitizeControlCharacters, sanitizeUri } from './sanitize';
+import { UrlStruct } from '../api';
+
+export type BuildUrlParams = {
+ baseUrl: string;
+ path: string;
+ pathParams?: Record | undefined;
+ queryParams?: Record | undefined;
+ encodePathParams?: boolean;
+};
+
+/**
+ * Builds a URL with the given base URL and parameters:
+ * - The `URL` API provides proper URL parsing and encoding.
+ * - The `path` is sanitized to prevent path traversal attacks.
+ * - Path and query parameters are sanitized to remove control characters.
+ *
+ * Ensures that the built URL is safe, valid, and sanitized.
+ *
+ * @param params - The parameters to build the URL from.
+ * @returns The built URL.
+ */
+export function buildUrl(params: BuildUrlParams): string {
+ const {
+ baseUrl,
+ path,
+ pathParams,
+ queryParams,
+ encodePathParams = true,
+ } = params;
+
+ // Validate and sanitize base URL
+ const sanitizedBaseUrl = sanitizeUri(baseUrl);
+ if (sanitizedBaseUrl === '') {
+ throw new Error('Invalid URL format');
+ }
+ assert(sanitizedBaseUrl, UrlStruct);
+
+ const pathWithParams = path.replace(/\{(\w+)\}/gu, (_match, key: string) => {
+ const value = pathParams?.[key];
+ if (value === undefined) {
+ throw new Error(`Path parameter ${key} is undefined`);
+ }
+ // Sanitize path parameter values to remove control characters
+ const sanitizedValue = sanitizeControlCharacters(value);
+ return encodePathParams
+ ? encodeURIComponent(sanitizedValue)
+ : sanitizedValue;
+ });
+
+ const cleanPath = pathWithParams
+ .replace(/^\/+/u, '') // Remove leading slashes
+ .replace(/\/+/gu, '/') // Replace multiple slashes with single
+ .replace(/\/+$/u, ''); // Remove trailing slashes
+
+ // Ensure base URL has trailing slash for proper path appending
+ const normalizedBaseUrl = sanitizedBaseUrl.endsWith('/')
+ ? sanitizedBaseUrl
+ : `${sanitizedBaseUrl}/`;
+
+ const url = new URL(cleanPath, normalizedBaseUrl);
+ Object.entries(queryParams ?? {})
+ .filter(([_key, value]) => value !== undefined)
+ .filter(([_key, value]) => value !== null)
+ .forEach(([key, value]) => {
+ if (value) {
+ // Sanitize query parameter values to remove control characters
+ const sanitizedValue = sanitizeControlCharacters(value);
+ url.searchParams.append(key, sanitizedValue);
+ }
+ });
+
+ const builtUrl = url.toString();
+ assert(builtUrl, UrlStruct);
+ return builtUrl;
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts
new file mode 100644
index 00000000..347f6a5a
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts
@@ -0,0 +1,207 @@
+import { Asset } from '@stellar/stellar-sdk';
+
+import { AssetType, KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../api';
+import {
+ getAssetReference,
+ getSlip44AssetId,
+ isClassicAssetId,
+ isSep41Id,
+ isSlip44Id,
+ isStellarAssetId,
+ parseClassicAssetCodeIssuer,
+ stellarAssetToCaip19,
+ toCaip19ClassicAssetId,
+ toCaip19Sep41AssetId,
+ toCaipAssetReference,
+} from './caip';
+
+const CLASSIC_ASSET_ID =
+ 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
+
+const SEP41_ASSET_ID =
+ 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J';
+
+const SLIP44_ASSET_ID = 'stellar:pubnet/slip44:148';
+
+describe('toCaip19ClassicAssetId', () => {
+ it('builds a CAIP-19 classic asset id from scope, code, and issuer', () => {
+ expect(
+ toCaip19ClassicAssetId(
+ KnownCaip2ChainId.Mainnet,
+ 'USDC',
+ 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ ).toBe(CLASSIC_ASSET_ID);
+ });
+});
+
+describe('toCaip19Sep41AssetId', () => {
+ it('builds a CAIP-19 sep41 asset id from scope and contract address', () => {
+ expect(
+ toCaip19Sep41AssetId(
+ KnownCaip2ChainId.Mainnet,
+ 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J',
+ ),
+ ).toBe(SEP41_ASSET_ID);
+ });
+});
+
+describe('isSlip44Id', () => {
+ it('returns true for known slip44 ids from the map', () => {
+ expect(isSlip44Id(KnownCaip19Slip44IdMap[KnownCaip2ChainId.Mainnet])).toBe(
+ true,
+ );
+ expect(isSlip44Id(KnownCaip19Slip44IdMap[KnownCaip2ChainId.Testnet])).toBe(
+ true,
+ );
+ });
+
+ it('returns false for non-slip44 asset ids', () => {
+ expect(isSlip44Id(CLASSIC_ASSET_ID)).toBe(false);
+ expect(isSlip44Id(SEP41_ASSET_ID)).toBe(false);
+ expect(isSlip44Id('unknown')).toBe(false);
+ });
+});
+
+describe('isSep41Id', () => {
+ it('returns true for a valid sep41 CAIP-19 id', () => {
+ expect(isSep41Id(SEP41_ASSET_ID)).toBe(true);
+ });
+
+ it('returns false for classic and slip44 ids', () => {
+ expect(isSep41Id(CLASSIC_ASSET_ID)).toBe(false);
+ expect(isSep41Id(SLIP44_ASSET_ID)).toBe(false);
+ });
+});
+
+describe('isClassicAssetId', () => {
+ it('returns true for a valid classic CAIP-19 id', () => {
+ expect(isClassicAssetId(CLASSIC_ASSET_ID)).toBe(true);
+ });
+
+ it('returns false for sep41 and slip44 ids', () => {
+ expect(isClassicAssetId(SEP41_ASSET_ID)).toBe(false);
+ expect(isClassicAssetId(SLIP44_ASSET_ID)).toBe(false);
+ });
+});
+
+describe('getAssetReference', () => {
+ it('returns the asset reference segment of a CAIP-19 id', () => {
+ expect(getAssetReference(CLASSIC_ASSET_ID)).toBe(
+ 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ );
+ expect(getAssetReference(SEP41_ASSET_ID)).toBe(
+ 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J',
+ );
+ expect(getAssetReference(SLIP44_ASSET_ID)).toBe('148');
+ });
+});
+
+describe('getSlip44AssetId', () => {
+ it('returns the slip44 id for the given chain scope', () => {
+ expect(getSlip44AssetId(KnownCaip2ChainId.Mainnet)).toBe(
+ `${KnownCaip2ChainId.Mainnet}/${AssetType.Native}:148`,
+ );
+ expect(getSlip44AssetId(KnownCaip2ChainId.Testnet)).toBe(
+ `${KnownCaip2ChainId.Testnet}/${AssetType.Native}:148`,
+ );
+ });
+});
+
+describe('toCaipAssetReference', () => {
+ it('returns the input unchanged when it has no colon', () => {
+ expect(toCaipAssetReference('USDC-GA5Z')).toBe('USDC-GA5Z');
+ });
+
+ it('joins code and issuer with a hyphen when given colon form', () => {
+ expect(toCaipAssetReference('USDC:GA5Z')).toBe('USDC-GA5Z');
+ });
+
+ it('throws when colon form is missing code or issuer', () => {
+ expect(() => toCaipAssetReference(':onlyIssuer')).toThrow(
+ 'Invalid asset reference: :onlyIssuer',
+ );
+ expect(() => toCaipAssetReference('onlyCode:')).toThrow(
+ 'Invalid asset reference: onlyCode:',
+ );
+ });
+});
+
+describe('parseClassicAssetCodeIssuer', () => {
+ it('parses hyphen-separated classic reference', () => {
+ expect(
+ parseClassicAssetCodeIssuer(
+ 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ ).toStrictEqual({
+ assetCode: 'USDC',
+ assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ });
+ });
+
+ it('parses colon-separated classic reference', () => {
+ expect(
+ parseClassicAssetCodeIssuer(
+ 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ ).toStrictEqual({
+ assetCode: 'USDC',
+ assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ });
+ });
+
+ it('throws when reference is missing code or issuer', () => {
+ expect(() => parseClassicAssetCodeIssuer('USDC-')).toThrow(
+ 'Invalid asset reference: USDC-',
+ );
+ expect(() => parseClassicAssetCodeIssuer(':G123')).toThrow(
+ 'Invalid asset reference: :G123',
+ );
+ });
+});
+
+describe('stellarAssetToCaip19', () => {
+ it('converts a native asset to a slip44 asset id', () => {
+ expect(
+ stellarAssetToCaip19(Asset.native(), KnownCaip2ChainId.Mainnet),
+ ).toBe(SLIP44_ASSET_ID);
+ });
+
+ it('converts a classic asset to a classic asset id', () => {
+ expect(
+ stellarAssetToCaip19(
+ new Asset(
+ 'USDC',
+ 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ KnownCaip2ChainId.Mainnet,
+ ),
+ ).toBe(CLASSIC_ASSET_ID);
+ });
+
+ it('throws an error if the asset is not a valid Stellar asset', () => {
+ expect(() =>
+ stellarAssetToCaip19(
+ 'invalid' as unknown as Asset,
+ KnownCaip2ChainId.Mainnet,
+ ),
+ ).toThrow('Invalid asset');
+ });
+});
+
+describe('isStellarAssetId', () => {
+ it('returns true for Stellar CAIP-19 asset ids', () => {
+ expect(isStellarAssetId(SLIP44_ASSET_ID)).toBe(true);
+ expect(isStellarAssetId(CLASSIC_ASSET_ID)).toBe(true);
+ expect(isStellarAssetId(SEP41_ASSET_ID)).toBe(true);
+ });
+
+ it('returns false for non-Stellar CAIP-19 asset ids', () => {
+ expect(isStellarAssetId('eip155:1/slip44:60')).toBe(false);
+ expect(
+ isStellarAssetId(
+ 'eip155:1/erc20:0x0000000000000000000000000000000000000000',
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.ts
new file mode 100644
index 00000000..b19e1178
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/caip.ts
@@ -0,0 +1,186 @@
+import { parseCaipAssetType } from '@metamask/utils';
+import { Asset } from '@stellar/stellar-sdk';
+
+import type {
+ KnownCaip19AssetIdOrSlip44Id,
+ KnownCaip19ClassicAssetId,
+ KnownCaip19Sep41AssetId,
+ KnownCaip19Slip44Id,
+ KnownCaip2ChainId,
+} from '../api';
+import {
+ AssetType,
+ KnownCaip19ClassicAssetStruct,
+ KnownCaip19Sep41AssetStruct,
+ KnownCaip19Slip44IdMap,
+} from '../api';
+
+/**
+ * Converts the given parameters to a CAIP-19 non native asset ID.
+ *
+ * @param scope - The CAIP-2 chain ID.
+ * @param assetCode - The asset code.
+ * @param assetIssuer - The asset issuer.
+ * @returns The CAIP-19 asset ID.
+ */
+export function toCaip19ClassicAssetId(
+ scope: KnownCaip2ChainId,
+ assetCode: string,
+ assetIssuer: string,
+): KnownCaip19ClassicAssetId {
+ return `${scope}/${AssetType.Token}:${assetCode}-${assetIssuer}`;
+}
+
+/**
+ * Converts the given parameters to a CAIP-19 Sep41 asset ID.
+ *
+ * @param scope - The CAIP-2 chain ID.
+ * @param contractAddress - The contract address.
+ * @returns The CAIP-19 Sep41 asset ID.
+ */
+export function toCaip19Sep41AssetId(
+ scope: KnownCaip2ChainId,
+ contractAddress: string,
+): KnownCaip19Sep41AssetId {
+ return `${scope}/${AssetType.Sep41}:${contractAddress}`;
+}
+
+/**
+ * Returns true when the CAIP-19 asset id belongs to a Stellar namespace.
+ *
+ * @param assetId - CAIP-19 asset id from any chain namespace.
+ * @returns True when the asset id uses the Stellar CAIP-2 prefix.
+ */
+export function isStellarAssetId(assetId: string): boolean {
+ return assetId.startsWith('stellar:');
+}
+
+/**
+ * Checks if the given asset ID is a slip44 ID.
+ *
+ * @param assetId - The CAIP-19 asset ID or slip44 ID.
+ * @returns True if the asset ID is a slip44 ID, false otherwise.
+ */
+export function isSlip44Id(
+ assetId: KnownCaip19AssetIdOrSlip44Id | string,
+): assetId is KnownCaip19Slip44Id {
+ return Object.values(KnownCaip19Slip44IdMap).includes(
+ assetId as KnownCaip19Slip44Id,
+ );
+}
+
+/**
+ * Returns true if the given asset ID is a Sep41 Asset ID.
+ *
+ * @param assetId - The CAIP-19 Sep41 Asset ID.
+ * @returns True if the asset ID is a Sep41 Asset ID, false otherwise.
+ */
+export function isSep41Id(
+ assetId: KnownCaip19AssetIdOrSlip44Id | string,
+): assetId is KnownCaip19Sep41AssetId {
+ const [error] = KnownCaip19Sep41AssetStruct.validate(assetId);
+ return error === undefined;
+}
+
+/**
+ * Checks if the given asset ID is a classic asset ID.
+ *
+ * @param assetId - The CAIP-19 asset ID or slip44 ID.
+ * @returns True if the asset ID is a classic asset ID, false otherwise.
+ */
+export function isClassicAssetId(
+ assetId: KnownCaip19AssetIdOrSlip44Id | string,
+): assetId is KnownCaip19ClassicAssetId {
+ const [error] = KnownCaip19ClassicAssetStruct.validate(assetId);
+ return error === undefined;
+}
+
+/**
+ * Returns the asset reference from a CAIP-19 asset id.
+ *
+ * @param assetId - CAIP-19 asset id.
+ * @returns Asset reference.
+ */
+export function getAssetReference(
+ assetId: KnownCaip19AssetIdOrSlip44Id,
+): string {
+ const { assetReference } = parseCaipAssetType(assetId);
+ return assetReference;
+}
+
+/**
+ * Returns the slip44 asset ID for the given scope.
+ *
+ * @param scope - The CAIP-2 chain ID.
+ * @returns The slip44 asset ID.
+ */
+export function getSlip44AssetId(
+ scope: KnownCaip2ChainId,
+): KnownCaip19Slip44Id {
+ return KnownCaip19Slip44IdMap[scope];
+}
+
+/**
+ * Converts the given asset reference to a CAIP-19 asset reference.
+ *
+ * @param assetRef - The asset reference.
+ * @returns The CAIP-19 asset reference.
+ */
+export function toCaipAssetReference(assetRef: string): string {
+ // TODO: change to sep41 asset reference detection
+ if (!assetRef.includes(':')) {
+ return assetRef;
+ }
+ // TODO: change to classic asset reference detection
+ const [assetCode, assetIssuer] = assetRef.split(':');
+ if (!assetCode || !assetIssuer) {
+ throw new Error(`Invalid asset reference: ${assetRef}`);
+ }
+ return `${assetCode}-${assetIssuer}`;
+}
+
+/**
+ * Parses classic asset code and issuer from CAIP-19 form (`CODE-ISSUER`) or colon form (`CODE:ISSUER`).
+ *
+ * @param assetReference - Classic asset reference segment from CAIP-19 or on-chain metadata.
+ * @returns Parsed asset code and issuer account id.
+ * @example
+ * ```
+ * parseClassicAssetCodeIssuer('USD-G1234567890123456789012345678901234567890');
+ * // { assetCode: 'USD', assetIssuer: 'G1234567890123456789012345678901234567890' }
+ * parseClassicAssetCodeIssuer('USD:G1234567890123456789012345678901234567890');
+ * // { assetCode: 'USD', assetIssuer: 'G1234567890123456789012345678901234567890' }
+ * ```
+ */
+export function parseClassicAssetCodeIssuer(assetReference: string): {
+ assetCode: string;
+ assetIssuer: string;
+} {
+ // TODO: change to classic asset reference detection
+ const separator = assetReference.includes(':') ? ':' : '-';
+ const [assetCode, assetIssuer] = assetReference.split(separator);
+ if (!assetCode || !assetIssuer) {
+ throw new Error(`Invalid asset reference: ${assetReference}`);
+ }
+ return { assetCode, assetIssuer };
+}
+
+/**
+ * Converts the given Stellar asset to a CAIP-19 asset ID.
+ *
+ * @param asset - The Stellar asset.
+ * @param scope - The CAIP-2 chain ID.
+ * @returns The CAIP-19 asset ID.
+ */
+export function stellarAssetToCaip19(
+ asset: Asset,
+ scope: KnownCaip2ChainId,
+): KnownCaip19ClassicAssetId | KnownCaip19Slip44Id {
+ if (!(asset instanceof Asset)) {
+ throw new Error(`Invalid asset`);
+ }
+ if (asset.isNative()) {
+ return getSlip44AssetId(scope);
+ }
+ return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer());
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts
new file mode 100644
index 00000000..e003043b
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts
@@ -0,0 +1,148 @@
+import { BigNumber } from 'bignumber.js';
+
+import {
+ toDisplayBalance,
+ formatFiat,
+ getFiatTicker,
+ isFiat,
+ normalizeAmount,
+ tokenToFiat,
+ toSmallestUnit,
+ removeTrailingZeros,
+} from './currency';
+
+describe('toSmallestUnit', () => {
+ it('converts human amount to stroops', () => {
+ expect(toSmallestUnit(new BigNumber('12.3456789')).toFixed(0)).toBe(
+ '123456789',
+ );
+ });
+
+ it('converts integer XLM to stroops', () => {
+ expect(toSmallestUnit(new BigNumber(1)).toFixed(0)).toBe('10000000');
+ });
+
+ it('uses custom decimal places when provided', () => {
+ expect(toSmallestUnit(new BigNumber('1.23'), 2).toFixed(0)).toBe('123');
+ });
+});
+
+describe('normalizeAmount', () => {
+ it('converts stroops to human amount', () => {
+ expect(normalizeAmount(new BigNumber(123456789)).toString()).toBe(
+ '12.3456789',
+ );
+ });
+
+ it('uses custom decimal places when provided', () => {
+ expect(normalizeAmount(new BigNumber(123), 2).toString()).toBe('1.23');
+ });
+});
+
+describe('toDisplayBalance', () => {
+ it('avoids scientific notation for one stroop', () => {
+ expect(toDisplayBalance(new BigNumber(1), 7)).toBe('0.0000001');
+ expect(normalizeAmount(new BigNumber(1), 7).toString()).toBe('1e-7');
+ });
+
+ it('trims trailing zeros while keeping significant fractional digits', () => {
+ expect(toDisplayBalance(new BigNumber(10), 7)).toBe('0.000001');
+ });
+});
+
+describe('removeTrailingZeros', () => {
+ it('removes trailing zeros from a decimal number', () => {
+ expect(removeTrailingZeros('12.345000')).toBe('12.345');
+ });
+
+ it('does not strip zeros from integer strings', () => {
+ expect(removeTrailingZeros('10')).toBe('10');
+ expect(removeTrailingZeros('0')).toBe('0');
+ });
+});
+
+describe('toSmallestUnit and normalizeAmount', () => {
+ it('roundtrips for representative values', () => {
+ const human = new BigNumber('12.3456789');
+ const stroops = toSmallestUnit(human);
+ expect(normalizeAmount(stroops).toString()).toBe(human.toString());
+ });
+});
+
+describe('formatFiat', () => {
+ it('rounds to two decimals before locale formatting and passes currency options', () => {
+ const toLocaleStringSpy = jest
+ .spyOn(Number.prototype, 'toLocaleString')
+ .mockImplementation(function formatFiatLocaleSpy(
+ this: number,
+ locales?: Intl.LocalesArgument,
+ options?: Intl.NumberFormatOptions,
+ ) {
+ const locale = locales;
+ expect(this.valueOf()).toBe(12.35);
+ expect(locale).toBe('en');
+ expect(options).toStrictEqual({
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 2,
+ minimumFractionDigits: 2,
+ });
+ return 'formatted';
+ });
+
+ expect(formatFiat('12.345', 'USD', 'en_US')).toBe('formatted');
+
+ expect(toLocaleStringSpy).toHaveBeenCalledTimes(1);
+
+ toLocaleStringSpy.mockRestore();
+ });
+
+ it('throws when amount is not finite', () => {
+ expect(() => formatFiat('NaN', 'USD', 'en-US')).toThrow(RangeError);
+ });
+});
+
+describe('tokenToFiat', () => {
+ it('multiplies token amount by rate as decimal strings', () => {
+ expect(tokenToFiat('10', '2.5')).toBe('25');
+ });
+
+ it('handles fractional token amounts', () => {
+ expect(tokenToFiat('0.5', '4')).toBe('2');
+ });
+});
+
+describe('isFiat', () => {
+ it('returns true for swift ISO4217 ids', () => {
+ expect(isFiat('swift:0/iso4217:USD')).toBe(true);
+ });
+
+ it('returns false for chain-prefixed fiat ids', () => {
+ expect(isFiat('eip155:1/swift:0/iso4217:USD')).toBe(false);
+ });
+
+ it('returns false for stellar asset ids', () => {
+ expect(
+ isFiat(
+ 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
+ ),
+ ).toBe(false);
+ });
+
+ it('returns false when ISO4217 segment is not exactly three letters', () => {
+ expect(isFiat('swift:0/iso4217:US')).toBe(false);
+ expect(isFiat('swift:0/iso4217:USDC')).toBe(false);
+ });
+});
+
+describe('getFiatTicker', () => {
+ it('throws when asset id is not fiat', () => {
+ expect(() => getFiatTicker('stellar:pubnet/slip44:148')).toThrow(
+ 'Passed assetId is not a fiat asset',
+ );
+ });
+
+ it('returns lowercase asset reference from parser', () => {
+ expect(getFiatTicker('swift:0/iso4217:EUR')).toBe('eur');
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts
new file mode 100644
index 00000000..4262d0b4
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts
@@ -0,0 +1,162 @@
+import { is } from '@metamask/superstruct';
+import type { CaipAssetType } from '@metamask/utils';
+import { parseCaipAssetType } from '@metamask/utils';
+import { BigNumber } from 'bignumber.js';
+
+import { FiatCaipAssetStruct } from '../api/asset';
+import { STELLAR_DECIMAL_PLACES } from '../constants';
+
+/**
+ * Converts an amount to the smallest unit of the asset.
+ *
+ * @example toSmallestUnit(new BigNumber('12.3456789')) // 123456789 stroops
+ *
+ * @param amount - The amount to convert.
+ * @param decimalPlaces - The number of decimal places to use.
+ * @returns The amount in the smallest unit.
+ */
+export function toSmallestUnit(
+ amount: BigNumber,
+ decimalPlaces: number = STELLAR_DECIMAL_PLACES,
+): BigNumber {
+ return amount.multipliedBy(BigNumber(10).pow(decimalPlaces));
+}
+
+/**
+ * Checks if an amount has decimal places.
+ *
+ * @param amount - The amount to check.
+ * @returns True if the amount has decimal places, false otherwise.
+ */
+export function hasDecimals(amount: BigNumber): boolean {
+ const decimalPlaces = amount.decimalPlaces();
+ if (decimalPlaces === null) {
+ return false;
+ }
+ return decimalPlaces > 0;
+}
+
+/**
+ * Converts an amount from the smallest unit to a human-readable amount.
+ *
+ * @example normalizeAmount(new BigNumber(123456789)) // 12.3456789
+ *
+ * @param amount - Amount in stroops.
+ * @param decimalPlaces - The number of decimal places to use.
+ * @returns The amount in the human-readable format.
+ */
+export function normalizeAmount(
+ amount: BigNumber,
+ decimalPlaces: number = STELLAR_DECIMAL_PLACES,
+): BigNumber {
+ return amount.dividedBy(BigNumber(10).pow(decimalPlaces));
+}
+
+/**
+ * Decimal string for keyring / MetaMask multichain balances.
+ * {@link BigNumber#toString} may use scientific notation (e.g. `1e-7`); the extension's
+ * `parseBalanceWithDecimals` only accepts `\d+(\.\d+)?`, so we use `toFixed` and trim
+ * redundant trailing zeros.
+ *
+ * @param amountInSmallestUnit - Balance in the asset's smallest unit (e.g. stroops).
+ * @param decimalPlaces - Asset decimals (e.g. 7 for XLM / classic Stellar assets).
+ * @returns Decimal string suitable for the keyring / multichain balance APIs.
+ */
+export function toDisplayBalance(
+ amountInSmallestUnit: BigNumber,
+ decimalPlaces: number = STELLAR_DECIMAL_PLACES,
+): string {
+ const fixed = normalizeAmount(amountInSmallestUnit, decimalPlaces).toFixed(
+ decimalPlaces,
+ );
+ return removeTrailingZeros(fixed);
+}
+
+/**
+ * Removes trailing zeros from a decimal number.
+ *
+ * @param amount - The amount to remove trailing zeros from.
+ * @returns The amount with trailing zeros removed.
+ */
+export function removeTrailingZeros(amount: string): string {
+ if (!amount.includes('.')) {
+ return amount;
+ }
+ const trimmed = amount.replace(/0+$/u, '').replace(/\.$/u, '');
+ return trimmed === '' ? '0' : trimmed;
+}
+
+/**
+ * Formats a number as currency (half-up rounded to 2 decimal places).
+ *
+ * @param amount - The amount of money.
+ * @param currency - The currency to format the amount as.
+ * @param locale - The locale to use for number formatting.
+ * @returns The formatted currency string.
+ * @throws {RangeError} If the amount is not a finite number.
+ */
+export function formatFiat(
+ amount: string,
+ currency: string,
+ locale: string,
+): string {
+ const rounded = new BigNumber(amount).decimalPlaces(
+ 2,
+ BigNumber.ROUND_HALF_UP,
+ );
+
+ if (!rounded.isFinite()) {
+ throw new RangeError('Amount must be a finite number for fiat formatting');
+ }
+
+ const amountNumber = rounded.toNumber();
+ const [localeCode] = locale.split('_');
+
+ return amountNumber.toLocaleString(localeCode, {
+ style: 'currency',
+ currency,
+ maximumFractionDigits: 2,
+ minimumFractionDigits: 2,
+ });
+}
+
+/**
+ * Converts a token amount to fiat currency using the provided conversion rate.
+ *
+ * @param tokenAmount - The amount of tokens to convert.
+ * @param rateConversion - The conversion rate from token to fiat.
+ * @returns The fiat value of the token amount.
+ */
+export function tokenToFiat(
+ tokenAmount: string,
+ rateConversion: string,
+): string {
+ const bigAmount = new BigNumber(tokenAmount);
+ return bigAmount.multipliedBy(new BigNumber(rateConversion)).toString();
+}
+
+/**
+ * Checks if a CAIP-19 asset type is a fiat asset.
+ *
+ * @param assetId - The CAIP-19 asset type.
+ * @returns True if the asset is a fiat asset, false otherwise.
+ */
+export function isFiat(assetId: CaipAssetType): boolean {
+ return is(assetId, FiatCaipAssetStruct);
+}
+
+/**
+ * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset ID.
+ *
+ * @param assetId - The CAIP-19 asset ID.
+ * @returns The fiat ticker.
+ */
+export function getFiatTicker(assetId: CaipAssetType): string {
+ if (!isFiat(assetId)) {
+ throw new Error('Passed assetId is not a fiat asset');
+ }
+
+ const fiatTicker = parseCaipAssetType(assetId).assetReference.toLowerCase();
+
+ return fiatTicker;
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts b/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts
new file mode 100644
index 00000000..59ad5f8e
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts
@@ -0,0 +1,350 @@
+import {
+ ChainDisconnectedError,
+ DisconnectedError,
+ InternalError,
+ InvalidInputError,
+ InvalidParamsError,
+ InvalidRequestError,
+ LimitExceededError,
+ MethodNotFoundError,
+ MethodNotSupportedError,
+ ParseError,
+ ResourceNotFoundError,
+ ResourceUnavailableError,
+ SnapError,
+ TransactionRejected,
+ UnauthorizedError,
+ UnsupportedMethodError,
+ UserRejectedRequestError,
+} from '@metamask/snaps-sdk';
+
+import {
+ InvalidHttpRequestParamsException,
+ isSnapRpcError,
+ rethrowIfInstanceElseThrow,
+ trackErrorIfNeeded,
+ withCatchAndThrowSnapError,
+} from './errors';
+import { logger } from './logger';
+import * as snapUtils from './snap';
+
+jest.mock('./logger');
+jest.mock('./snap');
+
+describe('errors', () => {
+ const mockLogger = logger as jest.Mocked;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rethrowIfInstanceElseThrow', () => {
+ class SampleDomainError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SampleDomainError';
+ }
+ }
+
+ class SampleDomainSubError extends SampleDomainError {}
+
+ class OtherDomainError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'OtherDomainError';
+ }
+ }
+
+ it('rethrows when error matches the sole constructor in the list', () => {
+ const original = new SampleDomainError('preserved');
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ original,
+ [SampleDomainError],
+ new SampleDomainError('fallback'),
+ ),
+ ).toThrow(original);
+ });
+
+ it('rethrows subclass instances as the base class match', () => {
+ const sub = new SampleDomainSubError('sub');
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ sub,
+ [SampleDomainError],
+ new SampleDomainError('fallback'),
+ ),
+ ).toThrow(sub);
+ });
+
+ it('throws fallback when error is not an instance of any listed class', () => {
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ new Error('generic'),
+ [SampleDomainError],
+ new SampleDomainError('wrapped'),
+ ),
+ ).toThrow(
+ expect.objectContaining({
+ name: 'SampleDomainError',
+ message: 'wrapped',
+ }),
+ );
+ });
+
+ it('rethrows when error matches any constructor in the list', () => {
+ const firstMatch = new SampleDomainError('first');
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ firstMatch,
+ [SampleDomainError, OtherDomainError],
+ new SampleDomainError('fallback'),
+ ),
+ ).toThrow(firstMatch);
+
+ const secondMatch = new OtherDomainError('second');
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ secondMatch,
+ [SampleDomainError, OtherDomainError],
+ new SampleDomainError('fallback'),
+ ),
+ ).toThrow(secondMatch);
+ });
+
+ it('throws fallback when error matches none of the constructors', () => {
+ expect(() =>
+ rethrowIfInstanceElseThrow(
+ new Error('generic'),
+ [SampleDomainError, OtherDomainError],
+ new SampleDomainError('wrapped'),
+ ),
+ ).toThrow(
+ expect.objectContaining({
+ name: 'SampleDomainError',
+ message: 'wrapped',
+ }),
+ );
+ });
+ });
+
+ describe('withCatchAndThrowSnapError', () => {
+ it('returns the result when the function succeeds', async () => {
+ const mockFn = jest.fn().mockResolvedValue('success');
+
+ const result = await withCatchAndThrowSnapError(mockFn);
+
+ expect(result).toBe('success');
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(mockLogger.error).not.toHaveBeenCalled();
+ });
+
+ it('handles and re-throws errors as SnapError', async () => {
+ const originalError = new Error('Test error');
+ const mockFn = jest.fn().mockRejectedValue(originalError);
+
+ await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
+ SnapError,
+ );
+
+ expect(mockFn).toHaveBeenCalledTimes(1);
+ expect(logger.error).toHaveBeenCalledTimes(1);
+ });
+
+ it('logs errors with the correct scope and error details', async () => {
+ const originalError = new Error('Test error');
+ const mockFn = jest.fn().mockRejectedValue(originalError);
+
+ try {
+ await withCatchAndThrowSnapError(mockFn);
+ } catch {
+ // Expected to throw
+ }
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ { error: expect.any(SnapError) },
+ expect.stringContaining(`[SnapError]`),
+ );
+
+ expect(mockLogger.error).toHaveBeenCalledTimes(1);
+ const logCall = mockLogger.error.mock.calls[0];
+ const loggedError = logCall?.[0] as { error: SnapError };
+ expect(loggedError.error).toBeInstanceOf(SnapError);
+ });
+
+ it('handles non-Error objects and converts them to SnapError', async () => {
+ const nonErrorValue = 'string error';
+ const mockFn = jest.fn().mockRejectedValue(nonErrorValue);
+
+ await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
+ SnapError,
+ );
+
+ expect(mockLogger.error).toHaveBeenCalledTimes(1);
+ const logCall = mockLogger.error.mock.calls[0];
+ const loggedError = logCall?.[0] as { error: SnapError };
+ expect(loggedError.error).toBeInstanceOf(SnapError);
+ });
+
+ it('handles null and undefined errors', async () => {
+ const mockFn = jest.fn().mockRejectedValue(null);
+
+ await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
+ SnapError,
+ );
+
+ expect(mockLogger.error).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves the original error message in the SnapError', async () => {
+ const originalError = new Error('Custom error message');
+ const mockFn = jest.fn().mockRejectedValue(originalError);
+
+ let caughtError: unknown;
+ try {
+ await withCatchAndThrowSnapError(mockFn);
+ } catch (error) {
+ caughtError = error;
+ }
+
+ expect(caughtError).toBeInstanceOf(SnapError);
+ const snapError = caughtError as SnapError;
+ expect(snapError.message).toBe('Custom error message');
+ });
+
+ it('handles async functions that return different types', async () => {
+ const testCases = [
+ { value: 42, type: 'number' },
+ { value: { key: 'value' }, type: 'object' },
+ { value: [1, 2, 3], type: 'array' },
+ { value: true, type: 'boolean' },
+ { value: null, type: 'null' },
+ ];
+
+ for (const testCase of testCases) {
+ const mockFn = jest.fn().mockResolvedValue(testCase.value);
+
+ const result = await withCatchAndThrowSnapError(mockFn);
+
+ expect(result).toBe(testCase.value);
+ expect(mockLogger.error).not.toHaveBeenCalled();
+ }
+ });
+
+ it('handles functions that throw different error types', async () => {
+ const errorTypes = [
+ new TypeError('Type error'),
+ new ReferenceError('Reference error'),
+ new RangeError('Range error'),
+ new SyntaxError('Syntax error'),
+ ];
+
+ for (const errorType of errorTypes) {
+ const mockFn = jest.fn().mockRejectedValue(errorType);
+
+ await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
+ SnapError,
+ );
+ }
+
+ expect(mockLogger.error).toHaveBeenCalledTimes(errorTypes.length);
+ const logCalls = mockLogger.error.mock.calls;
+ expect(logCalls).toHaveLength(errorTypes.length);
+
+ for (let i = 0; i < errorTypes.length; i++) {
+ const logCall = logCalls[i];
+ const loggedError = logCall?.[0] as { error: SnapError };
+ expect(loggedError.error).toBeInstanceOf(SnapError);
+ expect(loggedError.error.message).toBe(errorTypes[i]?.message);
+ }
+ });
+
+ it('includes error stack trace in the logged error', async () => {
+ const originalError = new Error('Test error');
+ originalError.stack = 'Error: Test error\n at test.js:1:1';
+ const mockFn = jest.fn().mockRejectedValue(originalError);
+
+ try {
+ await withCatchAndThrowSnapError(mockFn);
+ } catch {
+ // Expected to throw
+ }
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ { error: expect.any(SnapError) },
+ expect.stringContaining('[SnapError]'),
+ );
+ });
+
+ it('handles functions that throw promises', async () => {
+ const rejectedPromise = Promise.reject(new Error('Promise error'));
+ const mockFn = jest.fn().mockImplementation(async () => rejectedPromise);
+
+ await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
+ SnapError,
+ );
+
+ expect(mockLogger.error).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('isSnapRpcError', () => {
+ it.each([
+ new SnapError('Test error'),
+ new MethodNotFoundError(),
+ new UserRejectedRequestError(),
+ new MethodNotSupportedError(),
+ new ParseError(),
+ new ResourceNotFoundError(),
+ new ResourceUnavailableError(),
+ new TransactionRejected(),
+ new ChainDisconnectedError(),
+ new DisconnectedError(),
+ new UnauthorizedError(),
+ new UnsupportedMethodError(),
+ new InternalError(),
+ new InvalidInputError(),
+ new InvalidParamsError(),
+ new InvalidRequestError(),
+ new LimitExceededError(),
+ ])('return true if the error is $error', (error) => {
+ expect(isSnapRpcError(error)).toBe(true);
+ });
+ });
+
+ describe('trackErrorIfNeeded', () => {
+ let trackErrorSpy: jest.SpiedFunction;
+
+ beforeEach(() => {
+ trackErrorSpy = jest
+ .spyOn(snapUtils, 'trackError')
+ .mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ trackErrorSpy.mockRestore();
+ });
+
+ it('does not call trackError for UserRejectedRequestError', async () => {
+ await trackErrorIfNeeded(new UserRejectedRequestError());
+
+ expect(trackErrorSpy).not.toHaveBeenCalled();
+ });
+
+ it('calls trackError for unexpected errors', async () => {
+ const error = new Error('unexpected');
+
+ await trackErrorIfNeeded(error);
+
+ expect(trackErrorSpy).toHaveBeenCalledWith(error);
+ });
+
+ it('calls trackError for InvalidHttpRequestParamsException', async () => {
+ const error = new InvalidHttpRequestParamsException('bad params');
+
+ await trackErrorIfNeeded(error);
+
+ expect(trackErrorSpy).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/errors.ts b/merged-packages/stellar-wallet-snap/src/utils/errors.ts
new file mode 100644
index 00000000..112a2f45
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/errors.ts
@@ -0,0 +1,374 @@
+import {
+ ChainDisconnectedError,
+ DisconnectedError,
+ InternalError,
+ InvalidInputError,
+ InvalidParamsError,
+ InvalidRequestError,
+ LimitExceededError,
+ MethodNotFoundError,
+ MethodNotSupportedError,
+ ParseError,
+ ResourceNotFoundError,
+ ResourceUnavailableError,
+ SnapError,
+ TransactionRejected,
+ UnauthorizedError,
+ UnsupportedMethodError,
+ UserRejectedRequestError,
+} from '@metamask/snaps-sdk';
+import type { Struct } from '@metamask/superstruct';
+import { assert, enums, object, type } from '@metamask/superstruct';
+
+import type { ILogger } from './logger';
+import { logger as defaultLogger } from './logger';
+import { trackError } from './snap';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- must accept arbitrary `Error` subclass ctor signatures
+export type AnyErrorConstructor = abstract new (...args: any[]) => Error;
+
+/**
+ * Re-throws `error` when it is an instance of **any** constructor in `exceptionClasses` (subclasses
+ * count). Otherwise throws `fallback`. Typical use after logging in an API client `catch` so known
+ * domain errors propagate unchanged. Use a one-element array when only one type should match.
+ *
+ * @param error - Value from a `catch` clause.
+ * @param exceptionClasses - `Error` subclass constructors to match with `instanceof`, in order.
+ * @param fallback - Error to throw when nothing matches.
+ */
+export function rethrowIfInstanceElseThrow(
+ error: unknown,
+ exceptionClasses: readonly AnyErrorConstructor[],
+ fallback: Err,
+): never {
+ for (const ExceptionClass of exceptionClasses) {
+ if (error instanceof ExceptionClass) {
+ throw error;
+ }
+ }
+ throw fallback;
+}
+
+/** Union of Snap RPC error instance types (for type narrowing). */
+export type SnapRpcError =
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType
+ | InstanceType;
+
+/**
+ * Determines if the given error is a Snap RPC error.
+ *
+ * @param error - The error instance to be checked.
+ * @returns A boolean indicating whether the error is a Snap RPC error.
+ */
+export function isSnapRpcError(error: Error): error is SnapRpcError {
+ const errors = [
+ SnapError,
+ MethodNotFoundError,
+ UserRejectedRequestError,
+ MethodNotSupportedError,
+ ParseError,
+ ResourceNotFoundError,
+ ResourceUnavailableError,
+ TransactionRejected,
+ ChainDisconnectedError,
+ DisconnectedError,
+ UnauthorizedError,
+ UnsupportedMethodError,
+ InternalError,
+ InvalidInputError,
+ InvalidParamsError,
+ InvalidRequestError,
+ LimitExceededError,
+ ];
+ return errors.some((errType) => error instanceof errType);
+}
+
+export type StellarSnapExceptionOptions = {
+ cause?: unknown;
+ data?: Record;
+};
+
+/**
+ * A custom error class that extends the built-in Error class and adds a `data` property.
+ * Instances are serialized by {@link trackError} / `snap_trackError` and forwarded to
+ * MetaMask's Sentry pipeline, which applies platform-side scrubbing of sensitive fields.
+ */
+export class StellarSnapException extends Error {
+ readonly data?: Record;
+
+ constructor(message: string, options?: StellarSnapExceptionOptions) {
+ super(message, { cause: options?.cause });
+ this.name = new.target.name;
+ this.data = options?.data;
+
+ // Explicitly hides this constructor from the stack trace if supported.
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, this.constructor);
+ }
+ }
+}
+
+/** Network and transport error codes commonly surfaced by `fetch`. */
+const COMMON_HTTP_ERROR_CODES = [
+ 'CERT_HAS_EXPIRED',
+ 'UNABLE_TO_GET_ISSUER_CERT_LOC',
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
+ 'ECONNREFUSED',
+ 'ETIMEDOUT',
+ 'ENOTFOUND',
+ 'ECONNRESET',
+ 'AbortError',
+] as const;
+
+type CommonHttpErrorCode = (typeof COMMON_HTTP_ERROR_CODES)[number];
+
+const COMMON_HTTP_ERROR_CODE_SET = new Set(COMMON_HTTP_ERROR_CODES);
+
+const CommonHttpErrorCodesStruct = object({
+ cause: type({
+ code: enums([...COMMON_HTTP_ERROR_CODES]),
+ }),
+});
+
+/** Base for HTTP API client errors (transport, request shape, response shape). */
+export class ApiException extends StellarSnapException {
+ constructor(message: string, options?: StellarSnapExceptionOptions) {
+ super(message, options);
+ this.name = 'ApiException';
+ }
+}
+
+/** Network-level HTTP failure (timeout, DNS, TLS, abort, etc.). */
+export class HttpException extends ApiException {
+ constructor(message: string, options?: StellarSnapExceptionOptions) {
+ super(message, options);
+ this.name = 'HttpException';
+ }
+}
+
+/** Non-success HTTP status from a completed response. */
+export class HttpResponseException extends HttpException {
+ constructor(statusCode: number, options?: StellarSnapExceptionOptions) {
+ super(`HTTP error! status: ${statusCode}`, options);
+ this.name = 'HttpResponseException';
+ }
+}
+
+/** Request parameters failed validation before the HTTP call. */
+export class InvalidHttpRequestParamsException extends ApiException {
+ constructor(message: string, options?: StellarSnapExceptionOptions) {
+ super(message, options);
+ this.name = 'InvalidHttpRequestParamsException';
+ }
+}
+
+/** Response body failed validation after a successful HTTP status. */
+export class InvalidHttpResponseException extends ApiException {
+ constructor(message: string, options?: StellarSnapExceptionOptions) {
+ super(message, options);
+ this.name = 'InvalidHttpResponseException';
+ }
+}
+
+/**
+ * Validates API request parameters and throws {@link InvalidHttpRequestParamsException} on failure.
+ *
+ * @param params - Request payload or query parameters to validate.
+ * @param struct - Superstruct schema for the validated shape.
+ */
+export function assertHttpRequestParams(
+ params: unknown,
+ struct: Struct,
+): asserts params is Validated {
+ try {
+ assert(params, struct);
+ } catch (error) {
+ throw new InvalidHttpRequestParamsException(
+ 'Invalid API request parameters',
+ { cause: error },
+ );
+ }
+}
+
+/**
+ * Validates an API response body and throws {@link InvalidHttpResponseException} on failure.
+ *
+ * @param response - Parsed response body to validate.
+ * @param struct - Superstruct schema for `response`.
+ */
+export function assertHttpResponse(
+ response: Response,
+ struct: Struct,
+): void {
+ try {
+ assert(response, struct);
+ } catch (error) {
+ throw new InvalidHttpResponseException('Invalid API response', {
+ cause: error,
+ });
+ }
+}
+
+/**
+ * @param error - Value from a `catch` clause.
+ * @returns A known HTTP error code from `error.cause.code` or `error.code`.
+ */
+function getHttpErrorCode(error: Error): CommonHttpErrorCode | undefined {
+ if (CommonHttpErrorCodesStruct.is(error)) {
+ return error.cause?.code;
+ }
+
+ const { code } = error as { code?: string };
+ return code !== undefined && COMMON_HTTP_ERROR_CODE_SET.has(code)
+ ? (code as CommonHttpErrorCode)
+ : undefined;
+}
+
+/**
+ * Whether `error` represents a transient HTTP transport failure.
+ * Used by API clients to decide between fail-fast and partial-result recovery.
+ *
+ * @param error - Value from a `catch` clause or rejected batch entry.
+ * @returns `true` for network, timeout, abort, and non-2xx HTTP status errors.
+ */
+export function isHttpException(
+ error: unknown,
+): error is HttpException | (Error & { cause?: { code: string } }) {
+ if (!(error instanceof Error)) {
+ return false;
+ }
+
+ if (error instanceof HttpException) {
+ return true;
+ }
+
+ if (error.name === 'AbortError') {
+ return true;
+ }
+
+ return getHttpErrorCode(error) !== undefined;
+}
+
+/**
+ * Wraps raw transport errors in {@link HttpException}; leaves other values unchanged.
+ *
+ * @param error - Value from a `catch` clause.
+ * @returns `error` when it is already an {@link HttpException}, a new {@link HttpException}
+ * when `error` is a recognized transport failure, otherwise `error` unchanged.
+ */
+export function normalizeHttpException(error: unknown): unknown {
+ if (!(error instanceof Error)) {
+ return error;
+ }
+
+ if (error instanceof HttpException) {
+ return error;
+ }
+
+ if (!isHttpException(error)) {
+ return error;
+ }
+
+ const code = getHttpErrorCode(error);
+ return new HttpException(
+ code ? `HTTP error! cause: ${code}` : 'HTTP error!',
+ { cause: error },
+ );
+}
+
+/**
+ * @param error - Value from a `catch` clause.
+ * @returns Whether `error` is an invalid request/response shape error.
+ */
+export function isInvalidApiRequestOrResponseException(
+ error: unknown,
+): error is InvalidHttpRequestParamsException | InvalidHttpResponseException {
+ return (
+ error instanceof InvalidHttpRequestParamsException ||
+ error instanceof InvalidHttpResponseException
+ );
+}
+
+/**
+ * Checks if the error is a {@link StellarSnapException} (including subclasses).
+ *
+ * @param error - Value from a `catch` clause.
+ * @returns Whether `error` is a {@link StellarSnapException} (including subclasses).
+ */
+export function isStellarSnapException(
+ error: unknown,
+): error is StellarSnapException {
+ return error instanceof StellarSnapException;
+}
+
+/**
+ * A utility function that catches errors and throws them as SnapError.
+ *
+ * @param fn - The function to catch errors from.
+ * @param logger - The logger to use for logging errors. Defaults to the default logger.
+ * @returns The result of the function.
+ */
+export const withCatchAndThrowSnapError = async (
+ fn: () => Promise,
+ logger: ILogger = defaultLogger,
+): Promise => {
+ try {
+ return await fn();
+ } catch (errorInstance: unknown) {
+ await trackErrorIfNeeded(errorInstance);
+
+ let error: SnapRpcError;
+
+ if (errorInstance instanceof Error) {
+ if (isStellarSnapException(errorInstance)) {
+ error = new SnapError(errorInstance);
+ } else if (isSnapRpcError(errorInstance)) {
+ error = errorInstance;
+ } else {
+ error = new SnapError(errorInstance);
+ }
+ } else {
+ error = new SnapError(errorInstance as string | Error);
+ }
+
+ logger.error(
+ { error },
+ `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`,
+ );
+
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw error;
+ }
+};
+
+/**
+ * Sends `error` to Sentry when it represents an unexpected failure.
+ *
+ * Skips tracking for explicit user rejections.
+ * Callers should prefer this over {@link trackError} in swallow paths;
+ *
+ * @param error - Value from a `catch` clause.
+ */
+export async function trackErrorIfNeeded(error: unknown): Promise {
+ if (error instanceof UserRejectedRequestError) {
+ return;
+ }
+
+ await trackError(error);
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/i18n.ts b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts
new file mode 100644
index 00000000..8ef560a5
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts
@@ -0,0 +1,45 @@
+import en from '../../locales/en.json';
+import es from '../../locales/es.json';
+
+export const locales = {
+ en: en.messages,
+ es: es.messages,
+};
+
+export const FALLBACK_LANGUAGE: Locale = 'en';
+
+export type Locale = keyof typeof locales;
+/** When locale `messages` is an empty object, `keyof` is `never`; fall back to `string` for keys. */
+type MessageKeys = keyof (typeof locales)[typeof FALLBACK_LANGUAGE];
+export type LocalizedMessage = [MessageKeys] extends [never]
+ ? string
+ : MessageKeys;
+
+/**
+ * Fetches the translations based on the user's locale preference.
+ * Falls back to the default language if the preferred locale is not available.
+ *
+ * @param locale - The user's preferred locale.
+ * @returns A function that gets the translation for a given key.
+ */
+export function i18n(locale: string) {
+ // Needs to be casted as EN is the main language and we can have the case where
+ // messages are not yet completed for the other languages (e.g. empty `es` map).
+ const messages = (locales[locale as Locale] ??
+ locales[FALLBACK_LANGUAGE]) as Partial<
+ Record
+ >;
+
+ return (id: LocalizedMessage, replaces?: Record): string => {
+ let message = messages[id]?.message ?? id;
+
+ if (replaces && message) {
+ Object.keys(replaces).forEach((key) => {
+ const regex = new RegExp(`\\{${key}\\}`, 'gu');
+ message = message.replace(regex, replaces[key] ?? '');
+ });
+ }
+
+ return message;
+ };
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/index.ts b/merged-packages/stellar-wallet-snap/src/utils/index.ts
new file mode 100644
index 00000000..af3b9bc9
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/index.ts
@@ -0,0 +1,16 @@
+export * from './currency';
+export * from './logger';
+export * from './requestResponse';
+export * from './errors';
+export * from './snap';
+export * from './serialization';
+export * from './safeMerge';
+export * from './number';
+export * from './caip';
+export * from './buffer';
+export * from './buildUrl';
+export * from './sanitize';
+export * from './async';
+export * from './assert';
+export * from './array';
+export * from './i18n';
diff --git a/merged-packages/stellar-wallet-snap/src/utils/logger.ts b/merged-packages/stellar-wallet-snap/src/utils/logger.ts
new file mode 100644
index 00000000..870bf367
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/logger.ts
@@ -0,0 +1,98 @@
+/* eslint-disable no-empty-function */
+import { LogLevel } from '../api/loglevel';
+import { AppConfig } from '../config';
+
+/**
+ * A map of log levels to their priority.
+ * Used to determine if a log message should be logged based on the configured log level.
+ */
+const logLevelPriority = {
+ [LogLevel.SILENT]: 0,
+ [LogLevel.ERROR]: 1,
+ [LogLevel.WARN]: 2,
+ [LogLevel.INFO]: 3,
+ [LogLevel.DEBUG]: 4,
+ [LogLevel.ALL]: 5,
+};
+
+/**
+ * A simple logger utility that provides methods for logging messages at different levels.
+ * For now, it's just a wrapper around console.
+ */
+export type ILogger = {
+ log: (...args: unknown[]) => void;
+ info: (...args: unknown[]) => void;
+ warn: (...args: unknown[]) => void;
+ error: (...args: unknown[]) => void;
+ debug: (...args: unknown[]) => void;
+ logErrorWithDetails: (...args: unknown[]) => void;
+};
+
+/**
+ * A decorator function that is a no-op if the log level is less than the configured log level,
+ * and runs the decorated function otherwise.
+ *
+ * @param fn - The function to wrap.
+ * @param level - The log level to check against the configured log level.
+ * @returns The wrapped function.
+ */
+const withLogLevel =
+ (fn: (...args: unknown[]) => void, level: LogLevel) =>
+ (...args: unknown[]): void => {
+ if (
+ AppConfig.logLevel in logLevelPriority &&
+ logLevelPriority[level] <= logLevelPriority[AppConfig.logLevel]
+ ) {
+ fn(...args);
+ }
+ };
+
+/**
+ * A basic logger that wraps the console, extending its functionality to properly log errors.
+ */
+export const logger: ILogger = {
+ log: withLogLevel(console.log, LogLevel.ALL),
+ info: withLogLevel(console.info, LogLevel.INFO),
+ warn: withLogLevel(console.warn, LogLevel.WARN),
+ debug: withLogLevel(console.debug, LogLevel.DEBUG),
+ error: withLogLevel(console.error, LogLevel.ERROR),
+ logErrorWithDetails: withLogLevel((...args: unknown[]) => {
+ console.error(...args);
+ }, LogLevel.DEBUG),
+};
+
+/**
+ * A no-op logger that does nothing.
+ */
+export const noOpLogger: ILogger = {
+ log: () => {},
+ info: () => {},
+ warn: () => {},
+ debug: () => {},
+ error: () => {},
+ logErrorWithDetails: () => {},
+};
+
+/**
+ * A logger that prefixes the log message with a given prefix.
+ *
+ * @param _logger - The logger to prefix. Must be an object with the same methods as the ILogger interface.
+ * @param prefix - The prefix to add to the log message.
+ * @returns The prefixed logger.
+ */
+export const createPrefixedLogger = (
+ _logger: ILogger,
+ prefix: string,
+): ILogger => {
+ return new Proxy(_logger, {
+ get(target, prop: keyof ILogger): unknown {
+ const method = target[prop];
+ if (typeof method === 'function') {
+ return (...args: unknown[]) => {
+ return method.call(target, prefix, ...args);
+ };
+ }
+ return method;
+ },
+ });
+};
diff --git a/merged-packages/stellar-wallet-snap/src/utils/number.test.ts b/merged-packages/stellar-wallet-snap/src/utils/number.test.ts
new file mode 100644
index 00000000..895f2c7b
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/number.test.ts
@@ -0,0 +1,23 @@
+import { getLowestIndex } from './number';
+
+describe('getLowestIndex', () => {
+ it('returns 0 if the array is empty', () => {
+ expect(getLowestIndex([])).toBe(0);
+ });
+
+ it('returns the lowest index that is not in the array (gap at the end)', () => {
+ expect(getLowestIndex([0, 1, 2, 3])).toBe(4);
+ });
+
+ it('returns the lowest index from the gap that is not in the array', () => {
+ const data = Array.from({ length: 1000 }, (_, index) => index);
+ const first = data.splice(0, 400);
+ const second = data.splice(500, 400);
+
+ expect(getLowestIndex(first.concat(second))).toBe(400);
+ });
+
+ it('returns the lowest index that is not in the array (gap at the beginning)', () => {
+ expect(getLowestIndex([1, 2, 3])).toBe(0);
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/number.ts b/merged-packages/stellar-wallet-snap/src/utils/number.ts
new file mode 100644
index 00000000..4aa16942
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/number.ts
@@ -0,0 +1,22 @@
+/**
+ * This function finds the lowest index in a sorted array of numbers.
+ *
+ * @param sortedIndices - The sorted array of non-negative numbers to check.
+ * @returns The lowest unused index.
+ */
+export function getLowestIndex(sortedIndices: number[]): number {
+ const { length } = sortedIndices;
+ // Find the smallest i (0 <= i <= n) such that sortedIndices[i] > i (treat i === n as gap).
+ let low = 0;
+ let high = length;
+ while (low < high) {
+ const mid = Math.floor((low + high) / 2);
+ const value = sortedIndices[mid];
+ if (value !== undefined && value > mid) {
+ high = mid;
+ } else {
+ low = mid + 1;
+ }
+ }
+ return low;
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts
new file mode 100644
index 00000000..4ba0a3fe
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts
@@ -0,0 +1,93 @@
+import { KeyringRpcMethod } from '@metamask/keyring-api';
+import {
+ InvalidParamsError,
+ SnapError,
+ UnauthorizedError,
+} from '@metamask/snaps-sdk';
+import { string, object } from '@metamask/superstruct';
+
+import {
+ validateRequest,
+ validateResponse,
+ validateOrigin,
+} from './requestResponse';
+import { METAMASK_ORIGIN } from '../constants';
+
+const TestStruct = object({
+ url: string(),
+});
+
+describe('validateRequest', () => {
+ it('validates request parameters', () => {
+ const requestParams = { url: 'https://example.com' };
+
+ expect(() => validateRequest(requestParams, TestStruct)).not.toThrow();
+ });
+
+ it('rejects invalid request parameters', () => {
+ const requestParams = { url: 123 };
+
+ expect(() => validateRequest(requestParams, TestStruct)).toThrow(
+ InvalidParamsError,
+ );
+ });
+});
+
+describe('validateResponse', () => {
+ it('validates response', () => {
+ const response = { url: 'https://example.com' };
+
+ expect(() => validateResponse(response, TestStruct)).not.toThrow();
+ });
+
+ it('rejects invalid response', () => {
+ const response = { url: 123 };
+
+ expect(() => validateResponse(response, TestStruct)).toThrow(SnapError);
+ });
+});
+
+describe('validateOrigin', () => {
+ it.each([
+ KeyringRpcMethod.ListAccounts,
+ KeyringRpcMethod.GetAccount,
+ KeyringRpcMethod.CreateAccount,
+ KeyringRpcMethod.DeleteAccount,
+ KeyringRpcMethod.DiscoverAccounts,
+ KeyringRpcMethod.GetAccountBalances,
+ KeyringRpcMethod.SubmitRequest,
+ KeyringRpcMethod.ListAccountTransactions,
+ KeyringRpcMethod.ListAccountAssets,
+ ])('allows method %s for allowed dapps', (method) => {
+ const origin = 'http://localhost:3000';
+
+ expect(() => validateOrigin(origin, method)).not.toThrow();
+ });
+
+ it.each([
+ KeyringRpcMethod.ListAccounts,
+ KeyringRpcMethod.GetAccount,
+ KeyringRpcMethod.CreateAccount,
+ KeyringRpcMethod.DeleteAccount,
+ KeyringRpcMethod.DiscoverAccounts,
+ KeyringRpcMethod.GetAccountBalances,
+ KeyringRpcMethod.SubmitRequest,
+ KeyringRpcMethod.ListAccountTransactions,
+ KeyringRpcMethod.ListAccountAssets,
+ KeyringRpcMethod.ResolveAccountAddress,
+ KeyringRpcMethod.SetSelectedAccounts,
+ ])('allows method %s for metamask', (method) => {
+ const origin = METAMASK_ORIGIN;
+
+ expect(() => validateOrigin(origin, method)).not.toThrow();
+ });
+
+ it.each(['invalid', undefined, '', null])(
+ 'rejects unauthorized origin %s',
+ (origin) => {
+ expect(() =>
+ validateOrigin(origin as string, KeyringRpcMethod.ListAccounts),
+ ).toThrow(UnauthorizedError);
+ },
+ );
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts
new file mode 100644
index 00000000..2d96d0e9
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts
@@ -0,0 +1,70 @@
+/* eslint-disable @typescript-eslint/only-throw-error */
+import {
+ InvalidParamsError,
+ SnapError,
+ UnauthorizedError,
+} from '@metamask/snaps-sdk';
+import type { Struct } from '@metamask/superstruct';
+import { assert, create } from '@metamask/superstruct';
+
+import { originPermissions } from '../permissions';
+
+/**
+ * Validates that the origin is allowed to make the request.
+ * If the origin is not found or the method is not allowed, an UnauthorizedError is thrown.
+ *
+ * @param origin - The origin of the request.
+ * @param method - The method of the request.
+ * @throws {UnauthorizedError} If the origin is not found or the method is not allowed.
+ */
+export const validateOrigin = (origin: string, method: string): void => {
+ if (!origin) {
+ throw new UnauthorizedError('Origin not found');
+ }
+ if (!originPermissions.get(origin)?.has(method)) {
+ throw new UnauthorizedError('Permission denied');
+ }
+};
+
+/**
+ * Validates that the request parameters conform to the expected structure defined by the provided struct.
+ * Returns the validated (and coerced) value so handlers receive the correct types.
+ *
+ * @param requestParams - The request parameters to validate (typically unknown at call site).
+ * @param struct - The expected structure of the request parameters.
+ * @returns The validated and coerced request parameters.
+ * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure.
+ */
+export function validateRequest(
+ requestParams: unknown,
+ struct: Struct,
+): Type {
+ try {
+ return create(requestParams, struct);
+ } catch (validationError: unknown) {
+ if (validationError instanceof Error) {
+ throw new InvalidParamsError(validationError.message);
+ }
+ throw new InvalidParamsError('Invalid request parameters');
+ }
+}
+
+/**
+ * Validates that the response conforms to the expected structure defined by the provided struct.
+ *
+ * @param response - The response to validate (typically unknown at call site).
+ * @param struct - The expected structure of the response.
+ * @throws {SnapError} If the response does not conform to the expected structure.
+ */
+export function validateResponse(
+ response: unknown,
+ struct: Struct,
+): asserts response is Type {
+ try {
+ assert(response, struct);
+ } catch {
+ // Mask the error message for security reasons.
+ throw new SnapError('Invalid Response');
+ }
+}
+/* eslint-enable @typescript-eslint/only-throw-error */
diff --git a/merged-packages/stellar-wallet-snap/src/utils/safeMerge.test.ts b/merged-packages/stellar-wallet-snap/src/utils/safeMerge.test.ts
new file mode 100644
index 00000000..e0a6327e
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/safeMerge.test.ts
@@ -0,0 +1,119 @@
+import { safeMerge } from './safeMerge';
+
+describe('safeMerge', () => {
+ it('merges two objects and keeps existing values when the overrider has undefined', () => {
+ const overridee = { name: 'John', age: 25 };
+ const overrider = { name: undefined, title: 'Developer' };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ name: 'John',
+ age: 25,
+ title: 'Developer',
+ });
+ });
+
+ it('overrides values when the overrider has non-undefined values', () => {
+ const overridee = { name: 'John', age: 25 };
+ const overrider = { name: 'Jane', title: 'Engineer' };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ name: 'Jane',
+ age: 25,
+ title: 'Engineer',
+ });
+ });
+
+ it('handles empty objects', () => {
+ const overridee = {};
+ const overrider = {};
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({});
+ });
+
+ it('handles objects with null values', () => {
+ const overridee = { name: 'John', age: null };
+ const overrider = { name: null, title: 'Developer' };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ name: 'John',
+ age: null,
+ title: 'Developer',
+ });
+ });
+
+ it('replaces nested objects entirely', () => {
+ const overridee = {
+ user: {
+ name: 'John',
+ details: { age: 25 },
+ },
+ };
+ const overrider = {
+ user: {
+ name: undefined,
+ details: { location: 'NYC' },
+ },
+ };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ user: {
+ name: undefined,
+ details: { location: 'NYC' },
+ },
+ });
+ });
+
+ it('filters out empty objects in the overrider', () => {
+ const overridee = { name: 'John', settings: { theme: 'dark' } };
+ const overrider = { name: 'Jane', settings: {} };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ name: 'Jane',
+ settings: { theme: 'dark' },
+ });
+ });
+
+ it('keeps non-empty objects in the overrider', () => {
+ const overridee = { settings: { theme: 'dark' } };
+ const overrider = { settings: { language: 'en' } };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ settings: { language: 'en' },
+ });
+ });
+
+ it('merges multiple empty and non-empty nested objects', () => {
+ const overridee = {
+ a: { x: 1 },
+ b: { y: 2 },
+ c: { z: 3 },
+ };
+ const overrider = {
+ a: {},
+ b: { y: 5 },
+ c: {},
+ };
+
+ const result = safeMerge(overridee, overrider);
+
+ expect(result).toStrictEqual({
+ a: { x: 1 },
+ b: { y: 5 },
+ c: { z: 3 },
+ });
+ });
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/safeMerge.ts b/merged-packages/stellar-wallet-snap/src/utils/safeMerge.ts
new file mode 100644
index 00000000..f4b95d32
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/safeMerge.ts
@@ -0,0 +1,29 @@
+/**
+ * Shallowly merges two objects at the top level, keeping values from the overridee object when the overrider's
+ * corresponding top-level properties are undefined, null, or empty objects. Non-undefined values from the overrider
+ * take precedence. Empty objects in the overrider are filtered out to preserve the overridee's values. Nested objects
+ * are not merged recursively: if a nested property exists in the overrider, it replaces the overridee's value entirely.
+ *
+ * @param overridee - The object to override.
+ * @param overrider - The object to override with.
+ * @returns The merged object (using shallow, top-level merge semantics).
+ * @example
+ * const overridee = { name: 'John' };
+ * const overrider = { name: undefined, age: 30 };
+ * const merged = safeMerge(overridee, overrider);
+ * // merged is { name: 'John', age: 30 }
+ */
+export const safeMerge = (
+ overridee: TOverridee,
+ overrider: TOverrider,
+): TOverridee & TOverrider => ({
+ ...overridee,
+ ...(Object.fromEntries(
+ Object.entries(overrider).filter(
+ ([_key, value]) =>
+ value !== undefined &&
+ value !== null &&
+ (!value || typeof value !== 'object' || Object.keys(value).length > 0),
+ ),
+ ) as TOverrider),
+});
diff --git a/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts b/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts
new file mode 100644
index 00000000..dfcaa3d6
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts
@@ -0,0 +1,44 @@
+/**
+ * Removes control characters from a string.
+ * Control characters can be used for injection attacks and should be stripped from user input.
+ *
+ * @param input - The string to sanitize.
+ * @returns The sanitized string with control characters removed.
+ */
+export function sanitizeControlCharacters(input: string): string {
+ if (!input || typeof input !== 'string') {
+ return '';
+ }
+
+ // Remove all control characters except tab
+ // eslint-disable-next-line no-control-regex
+ return input.replace(/[\u0000-\u0008\u000A-\u001F\u007F]/gu, '');
+}
+
+/**
+ * Validates and sanitizes a URI.
+ *
+ * @param uri - The URI to validate and sanitize.
+ * @returns The sanitized URI or empty string if invalid.
+ */
+export function sanitizeUri(uri: string): string {
+ if (!uri || typeof uri !== 'string') {
+ return '';
+ }
+
+ const sanitized = sanitizeControlCharacters(uri);
+
+ try {
+ const url = new URL(sanitized);
+ const allowedProtocols = ['http:', 'https:', 'wss:'];
+ if (!allowedProtocols.includes(url.protocol)) {
+ return '';
+ }
+ if (sanitized.length > 2048) {
+ return '';
+ }
+ return sanitized;
+ } catch {
+ return '';
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/src/utils/serialization.test.ts b/merged-packages/stellar-wallet-snap/src/utils/serialization.test.ts
new file mode 100644
index 00000000..0d0d9cd6
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/serialization.test.ts
@@ -0,0 +1,198 @@
+/* eslint-disable jest/prefer-strict-equal */
+import { BigNumber } from 'bignumber.js';
+
+import { serialize, deserialize } from './serialization';
+
+describe('serialize', () => {
+ it('serializes primitive values', () => {
+ expect(serialize('test')).toBe('test');
+ expect(serialize(42)).toBe(42);
+ expect(serialize(true)).toBe(true);
+ expect(serialize(null)).toBeNull();
+ expect(serialize(undefined)).toStrictEqual({ __type: 'undefined' });
+ });
+
+ it('serializes special types', () => {
+ expect(serialize(BigInt(9007199254740991))).toStrictEqual({
+ __type: 'bigint',
+ value: '9007199254740991',
+ });
+ expect(serialize(new BigNumber('123456789.123456789'))).toStrictEqual({
+ __type: 'BigNumber',
+ value: '123456789.123456789',
+ });
+ });
+
+ it('serializes arrays with mixed types', () => {
+ const input = [undefined, new BigNumber('123'), BigInt('456')];
+ const result = serialize(input);
+ expect(result).toStrictEqual([
+ { __type: 'undefined' },
+ { __type: 'BigNumber', value: '123' },
+ { __type: 'bigint', value: '456' },
+ ]);
+ });
+
+ it('serializes objects with nested structures', () => {
+ const input = {
+ nested: {
+ bigNumber: new BigNumber('123.456'),
+ bigint: BigInt('9007199254740991'),
+ undefined,
+ },
+ array: [new BigNumber('789.012'), BigInt('9007199254740992')],
+ };
+
+ const result = serialize(input);
+
+ expect(result).toStrictEqual({
+ nested: {
+ bigNumber: { __type: 'BigNumber', value: '123.456' },
+ bigint: { __type: 'bigint', value: '9007199254740991' },
+ undefined: { __type: 'undefined' },
+ },
+ array: [
+ { __type: 'BigNumber', value: '789.012' },
+ { __type: 'bigint', value: '9007199254740992' },
+ ],
+ });
+ });
+
+ it('serializes empty objects and arrays', () => {
+ const input = {
+ emptyObject: {},
+ emptyArray: [],
+ };
+
+ const result = serialize(input);
+
+ expect(result).toStrictEqual(input);
+ });
+
+ it('serializes deeply nested structures', () => {
+ const input = {
+ level1: {
+ level2: {
+ level3: {
+ bigint: BigInt('123'),
+ undefined,
+ },
+ },
+ },
+ };
+
+ const result = serialize(input);
+
+ expect(result).toStrictEqual({
+ level1: {
+ level2: {
+ level3: {
+ bigint: { __type: 'bigint', value: '123' },
+ undefined: { __type: 'undefined' },
+ },
+ },
+ },
+ });
+ });
+
+ it('serializes Uint8Array', () => {
+ const input = new Uint8Array([1, 2, 3]);
+ const result = serialize(input);
+ expect(result).toStrictEqual({
+ __type: 'Uint8Array',
+ value: 'AQID',
+ });
+ });
+});
+
+describe('deserialize', () => {
+ it('deserializes primitive values', () => {
+ expect(deserialize('test')).toBe('test');
+ expect(deserialize(42)).toBe(42);
+ expect(deserialize(true)).toBe(true);
+ expect(deserialize(null)).toBeNull();
+ });
+
+ it('deserializes special serialized types', () => {
+ expect(deserialize({ __type: 'undefined' })).toBeUndefined();
+ expect(
+ deserialize({ __type: 'bigint', value: '9007199254740991' }),
+ ).toStrictEqual(BigInt(9007199254740991));
+ expect(
+ deserialize({ __type: 'BigNumber', value: '123456789.123456789' }),
+ ).toStrictEqual(new BigNumber('123456789.123456789'));
+ });
+
+ it('deserializes arrays with mixed types', () => {
+ expect(
+ deserialize([
+ 1,
+ 'hello',
+ true,
+ null,
+ { __type: 'undefined' },
+ { __type: 'bigint', value: '9007199254740991' },
+ { __type: 'BigNumber', value: '123456789.123456789' },
+ ]),
+ ).toEqual([
+ 1,
+ 'hello',
+ true,
+ null,
+ undefined,
+ BigInt(9007199254740991),
+ new BigNumber('123456789.123456789'),
+ ]);
+ });
+
+ it('deserializes objects with nested structures', () => {
+ const input = {
+ nested: {
+ bigNumber: { __type: 'BigNumber', value: '123.456' },
+ bigint: { __type: 'bigint', value: '9007199254740991' },
+ undefined: { __type: 'undefined' },
+ },
+ array: [
+ { __type: 'BigNumber', value: '789.012' },
+ { __type: 'bigint', value: '9007199254740992' },
+ ],
+ };
+
+ const result = deserialize(input);
+
+ expect(result).toEqual({
+ nested: {
+ bigNumber: new BigNumber('123.456'),
+ bigint: BigInt('9007199254740991'),
+ undefined,
+ },
+ array: [new BigNumber('789.012'), BigInt('9007199254740992')],
+ });
+ });
+
+ it('handles non-undefined falsy values correctly', () => {
+ const input = {
+ zero: 0,
+ emptyString: '',
+ falseValue: false,
+ nullValue: null,
+ };
+
+ const result = deserialize(input);
+
+ expect(result).toStrictEqual({
+ zero: 0,
+ emptyString: '',
+ falseValue: false,
+ nullValue: null,
+ });
+ });
+
+ it('deserializes Uint8Array', () => {
+ const input = { __type: 'Uint8Array', value: 'AQID' };
+ const result = deserialize(input);
+ expect(result).toStrictEqual(new Uint8Array([1, 2, 3]));
+ });
+});
+
+/* eslint-enable jest/prefer-strict-equal */
diff --git a/merged-packages/stellar-wallet-snap/src/utils/serialization.ts b/merged-packages/stellar-wallet-snap/src/utils/serialization.ts
new file mode 100644
index 00000000..94ed48e5
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/serialization.ts
@@ -0,0 +1,120 @@
+import type { Json } from '@metamask/snaps-sdk';
+import { BigNumber } from 'bignumber.js';
+import { cloneDeepWith } from 'lodash';
+
+/**
+ * A primitive value that can be serialized to JSON using the `serialize` function.
+ */
+export type Serializable =
+ | Json
+ | undefined
+ | null
+ | bigint
+ | BigNumber
+ | Uint8Array
+ | Serializable[]
+ | {
+ [prop: string]: Serializable;
+ };
+
+/**
+ * Serializes the passed value to a JSON object so it can be stored in JSON-serializable storage like the Snap state and interface context.
+ * It transforms non-JSON-serializable values into a specific JSON-serializable representation that can be deserialized later.
+ *
+ * @param value - The value to serialize.
+ * @returns The serialized value.
+ * @throws If an unsupported case is encountered. This indicates a missing implementation.
+ */
+export const serialize = (value: Serializable): Json =>
+ cloneDeepWith(value, (val: unknown) => {
+ if (val === undefined) {
+ return {
+ __type: 'undefined',
+ };
+ }
+
+ if (val instanceof BigNumber) {
+ return {
+ __type: 'BigNumber',
+ value: val.toString(),
+ };
+ }
+
+ if (typeof val === 'bigint') {
+ return {
+ __type: 'bigint',
+ value: val.toString(),
+ };
+ }
+
+ if (val instanceof Uint8Array) {
+ // Convert Uint8Array to base64 string without using Buffer.
+ // Build string via array + join to avoid O(n^2) from repeated concatenation.
+ const binaryString = Array.from(val, (byte) =>
+ String.fromCharCode(byte),
+ ).join('');
+ return {
+ __type: 'Uint8Array',
+ value: btoa(binaryString),
+ };
+ }
+
+ // Return undefined to let lodash handle the cloning of other values
+ return undefined;
+ });
+
+/**
+ * Deserializes the passed value from a JSON object back to its original values.
+ * It transforms the JSON-serializable representation of non-JSON-serializable values back into their original values.
+ *
+ * @param serializedValue - The value to deserialize.
+ * @returns The deserialized value.
+ */
+export const deserialize = (serializedValue: Json): Serializable =>
+ JSON.parse(JSON.stringify(serializedValue), (_key, value) => {
+ if (!value) {
+ return value;
+ }
+
+ if (value.__type === 'undefined') {
+ return undefined;
+ }
+
+ if (value.__type === 'BigNumber') {
+ return new BigNumber(value.value);
+ }
+
+ if (value.__type === 'bigint') {
+ return BigInt(value.value);
+ }
+
+ if (value.__type === 'Uint8Array') {
+ const binaryString = atob(value.value);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let index = 0; index < binaryString.length; index++) {
+ bytes[index] = binaryString.charCodeAt(index);
+ }
+ return bytes;
+ }
+
+ return value;
+ });
+
+/**
+ * Serializes the passed value to a string.
+ *
+ * @param params - The parameters to serialize.
+ * @param params.value - The value to serialize.
+ * @param params.replacer - The replacer function to use.
+ * @param params.indent - The indent to use.
+ * @returns The serialized value.
+ */
+export const serializeToString = ({
+ value,
+ replacer = null,
+ indent = 2,
+}: {
+ value: Serializable;
+ replacer?: (number | string)[] | null;
+ indent?: number;
+}): string => JSON.stringify(serialize(value), replacer, indent);
diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts
new file mode 100644
index 00000000..126366ed
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts
@@ -0,0 +1,654 @@
+import type { JsonSLIP10Node } from '@metamask/key-tree';
+import type { EntropySourceId } from '@metamask/keyring-api';
+import { getJsonError } from '@metamask/snaps-sdk';
+import type {
+ ComponentOrElement,
+ DialogResult,
+ EntropySource,
+ GetClientStatusResult,
+ GetPreferencesResult,
+ Json,
+ ResolveInterfaceResult,
+ SnapsProvider,
+ UpdateInterfaceResult,
+} from '@metamask/snaps-sdk';
+import { ensureError } from '@metamask/utils';
+
+import { StellarSnapException } from './errors';
+import { logger } from './logger';
+import { serialize, deserialize } from './serialization';
+import type { Serializable } from './serialization';
+
+export enum Duration {
+ OneSecond = 'PT1S',
+ TwoSeconds = 'PT2S',
+ FiveSeconds = 'PT5S',
+ TwentySeconds = 'PT20S',
+ ThirtySeconds = 'PT30S',
+ OneMinute = 'PT1M',
+ FiveMinutes = 'PT5M',
+ TenMinutes = 'PT10M',
+ ThirtyMinutes = 'PT30M',
+ OneHour = 'PT1H',
+}
+
+/**
+ * Enum for transaction tracking event types.
+ */
+export enum TransactionEventType {
+ TransactionAdded = 'Transaction Added',
+ TransactionRejected = 'Transaction Rejected',
+ TransactionApproved = 'Transaction Approved',
+ TransactionSubmitted = 'Transaction Submitted',
+ TransactionFinalized = 'Transaction Finalized',
+}
+
+/**
+ * Enum for security alert tracking event types.
+ */
+export enum SecurityEventType {
+ SecurityAlertDetected = 'Security Alert Detected',
+ SecurityScanCompleted = 'Security Scan Completed',
+}
+
+/**
+ * Returns the Snap provider.
+ *
+ * @returns The Snap provider.
+ */
+export function getSnapProvider(): SnapsProvider {
+ // snap is a global variable provided by the Snap SDK
+ return snap;
+}
+
+/**
+ * Retrieves a `SLIP10NodeInterface` object for the specified path and curve.
+ *
+ * @see https://docs.metamask.io/snaps/reference/snaps-api/#snap_getbip32entropy
+ *
+ * @param params - The parameters for the key derivation.
+ * @param params.entropySource - The entropy source to use for key derivation.
+ * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`.
+ * @param params.curve - The elliptic curve to use for key derivation.
+ * @returns A Promise that resolves to a `SLIP10NodeInterface` object.
+ * @throws {StellarSnapException} If the Snap RPC call fails (message is intentionally generic).
+ */
+export async function getBip32Entropy({
+ entropySource,
+ path,
+ curve,
+}: {
+ entropySource?: EntropySourceId | undefined;
+ path: string[];
+ curve: 'secp256k1' | 'ed25519';
+}): Promise {
+ try {
+ return await getSnapProvider().request({
+ method: 'snap_getBip32Entropy',
+ params: {
+ path,
+ curve,
+ ...(entropySource ? { source: entropySource } : {}),
+ },
+ });
+ } catch {
+ // For security reasons, we do not want to expose the error message to the user
+ throw new StellarSnapException('Failed to get BIP32 entropy from Snap');
+ }
+}
+
+/**
+ * List all entropy sources.
+ *
+ * @returns An array of entropy sources.
+ */
+export async function listEntropySources(): Promise {
+ return getSnapProvider().request({
+ method: 'snap_listEntropySources',
+ });
+}
+
+/**
+ * Retrieves the default entropy source.
+ * The default entropy source is the entropy source that is used by default when no entropy source is provided.
+ *
+ * @returns A Promise that resolves to the default entropy source.
+ * @throws An error if no default entropy source is found.
+ */
+export async function getDefaultEntropySource(): Promise {
+ const entropySources = await listEntropySources();
+ const defaultEntropySource = entropySources.find(({ primary }) => primary);
+
+ if (!defaultEntropySource) {
+ // This can never happen because the Snap SDK always returns a default entropy source
+ throw new Error('No default entropy source found');
+ }
+
+ return defaultEntropySource.id;
+}
+
+/**
+ * Updates the state.
+ *
+ * @param params - The parameters for the state update.
+ * @param params.newState - The new state to set.
+ * @param params.encrypted - Whether the state is encrypted.
+ * @returns A Promise that resolves when the state is updated.
+ */
+export async function updateState({
+ newState,
+ encrypted,
+}: {
+ newState: Record;
+ encrypted: boolean;
+}): Promise {
+ await getSnapProvider().request({
+ method: 'snap_manageState',
+ params: {
+ operation: 'update',
+ newState: serialize(newState) as Record,
+ encrypted,
+ },
+ });
+}
+
+/**
+ * Sets the state for the given key.
+ *
+ * @param params - The parameters for the state update.
+ * @param params.key - The key (path) to set.
+ * @param params.newState - The new state to set.
+ * @param params.encrypted - Whether the state is encrypted.
+ * @returns A Promise that resolves when the state is updated.
+ */
+export async function setState({
+ key,
+ newState,
+ encrypted,
+}: {
+ key: string;
+ newState: Serializable;
+ encrypted: boolean;
+}): Promise {
+ await getSnapProvider().request({
+ method: 'snap_setState',
+ params: {
+ key,
+ value: serialize(newState),
+ encrypted,
+ },
+ });
+}
+
+/**
+ * Retrieves the state for the given key.
+ *
+ * @param params - The parameters for the state retrieval.
+ * @param params.key - (optional) The key to get the state for. If not provided, the whole state is returned.
+ * @param params.encrypted - Whether the state is encrypted.
+ * @returns The state for the given key.
+ */
+export async function getState({
+ key,
+ encrypted,
+}: {
+ key?: string;
+ encrypted: boolean;
+}): Promise {
+ const state = await getSnapProvider().request({
+ method: 'snap_getState',
+ params: {
+ ...(key ? { key } : {}),
+ encrypted,
+ },
+ });
+
+ if (state === null || state === undefined) {
+ return undefined;
+ }
+
+ return deserialize(state);
+}
+
+/**
+ * Retrieves the client status (locked/unlocked) in this case from MM.
+ *
+ * @returns An object containing the status.
+ */
+export async function getClientStatus(): Promise {
+ return getSnapProvider().request({
+ method: 'snap_getClientStatus',
+ });
+}
+
+/**
+ * Schedules a background event.
+ *
+ * @param options - The options for the background event.
+ * @param options.method - The method to call.
+ * @param options.params - The params to pass to the method.
+ * @param options.duration - The duration to wait before the event is scheduled.
+ * @returns A promise that resolves to a string.
+ */
+export async function scheduleBackgroundEvent({
+ method,
+ params = {},
+ duration,
+}: {
+ method: string;
+ params?: Record;
+ duration: Duration;
+}): Promise {
+ return getSnapProvider().request({
+ method: 'snap_scheduleBackgroundEvent',
+ params: {
+ duration,
+ request: {
+ method,
+ params,
+ },
+ },
+ });
+}
+
+/**
+ * Checks if an error is an "interface not found" error.
+ * Detects JSON-RPC errors thrown when an interface has been dismissed by the user.
+ *
+ * @param error - The error to check.
+ * @returns True if the error indicates the interface was not found.
+ */
+function isInterfaceNotFoundError(error: unknown): boolean {
+ let message = '';
+ if (error instanceof Error) {
+ message = error.message.toLowerCase();
+ } else if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error
+ ) {
+ message = (error.message as string).toLowerCase();
+ } else {
+ message = String(error).toLowerCase();
+ }
+ return message.includes('interface') && message.includes('not found');
+}
+
+/**
+ * Create a UI interface with the provided UI component and context.
+ *
+ * @param ui - The UI component to render.
+ * @param context - The initial context object to associate with the interface.
+ * @returns The created interface id.
+ */
+export async function createInterface(
+ ui: ComponentOrElement,
+ context: TContext & Record,
+): Promise {
+ return getSnapProvider().request({
+ method: 'snap_createInterface',
+ params: {
+ ui,
+ context,
+ },
+ });
+}
+
+/**
+ * Update an existing UI interface with a new UI component and context.
+ * Returns null if the interface has been dismissed by the user.
+ *
+ * @param id - The interface id returned from createInterface.
+ * @param ui - The new UI component to render.
+ * @param context - The updated context object to associate with the interface.
+ * @returns True if the interface was updated, or null if it was not found.
+ */
+export async function updateInterfaceIfExists(
+ id: string,
+ ui: ComponentOrElement,
+ context: TContext & Record,
+): Promise {
+ try {
+ await getSnapProvider().request({
+ method: 'snap_updateInterface',
+ params: {
+ id,
+ ui,
+ context,
+ },
+ });
+ return true;
+ } catch (error) {
+ if (isInterfaceNotFoundError(error)) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/**
+ * Gets the context of an interface by its ID.
+ * Returns null if the interface has been dismissed by the user.
+ *
+ * @param id - The ID for the interface.
+ * @returns The context object associated with the interface, or null if not found.
+ */
+export async function getInterfaceContextIfExists(
+ id: string,
+): Promise {
+ try {
+ const rawContext = await getSnapProvider().request({
+ method: 'snap_getInterfaceContext',
+ params: {
+ id,
+ },
+ });
+
+ if (!rawContext) {
+ return null;
+ }
+
+ return rawContext as TContext;
+ } catch (error) {
+ if (isInterfaceNotFoundError(error)) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/**
+ * Updates the context of an interface by its ID without changing the UI.
+ * Note: This is a helper that re-uses the existing UI.
+ *
+ * @param id - The ID for the interface.
+ * @param ui - The UI component.
+ * @param context - The updated context object.
+ * @returns The update interface result.
+ */
+export async function updateInterfaceWithContext<
+ TContext extends Record,
+>(
+ id: string,
+ ui: ComponentOrElement,
+ context: TContext,
+): Promise {
+ return getSnapProvider().request({
+ method: 'snap_updateInterface',
+ params: {
+ id,
+ ui,
+ context,
+ },
+ });
+}
+
+/**
+ * Shows a dialog using the provided ID.
+ *
+ * @param id - The ID for the dialog.
+ * @param type - The type of dialog to show. Defaults to 'custom'.
+ * @returns A promise that resolves to a string.
+ */
+export async function showDialog(
+ id: string,
+ type?: 'alert' | 'prompt' | 'confirmation',
+): Promise {
+ return getSnapProvider().request({
+ method: 'snap_dialog',
+ params: {
+ // If type is not provided, it will default to 'custom'.
+ // @see https://docs.metamask.io/snaps/features/custom-ui/dialogs/#display-a-custom-dialog
+ ...(type ? { type } : {}),
+ id,
+ },
+ });
+}
+
+/**
+ * Resolve a dialog using the provided ID.
+ *
+ * @param id - The ID for the interface to update.
+ * @param value - The result to resolve the interface with.
+ * @returns An object containing the state of the interface.
+ */
+export async function resolveInterface(
+ id: string,
+ value: Json,
+): Promise {
+ return getSnapProvider().request({
+ method: 'snap_resolveInterface',
+ params: {
+ id,
+ value,
+ },
+ });
+}
+
+/**
+ * Get preferences from snap.
+ *
+ * @returns A promise that resolves to snap preferences.
+ */
+export async function getPreferences(): Promise {
+ return getSnapProvider().request({
+ method: 'snap_getPreferences',
+ });
+}
+
+/**
+ * Track an event in MetaMask analytics.
+ *
+ * @param event - The event name to track.
+ * @param properties - Additional properties to include with the event.
+ */
+export async function trackEvent(
+ event: string,
+ properties: Record,
+): Promise {
+ try {
+ await getSnapProvider().request({
+ method: 'snap_trackEvent',
+ params: {
+ event: {
+ event,
+ properties,
+ },
+ },
+ });
+ } catch (error) {
+ // Silently fail if tracking fails - we don't want to interrupt the user flow.
+ logger.warn({ error }, 'Failed to track event via snap_trackEvent');
+ }
+}
+
+/**
+ * Track a "Transaction Added" event when a transaction confirmation is shown.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ */
+export async function trackTransactionAdded(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+}): Promise {
+ await trackEvent(TransactionEventType.TransactionAdded, {
+ message: 'Snap transaction added',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ });
+}
+
+/**
+ * Track a "Transaction Rejected" event when user rejects a transaction.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ */
+export async function trackTransactionRejected(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+}): Promise {
+ await trackEvent(TransactionEventType.TransactionRejected, {
+ message: 'Snap transaction rejected',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ });
+}
+
+/**
+ * Track a "Transaction Submitted" event when a transaction is successfully broadcast.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ */
+export async function trackTransactionSubmitted(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+}): Promise {
+ await trackEvent(TransactionEventType.TransactionSubmitted, {
+ message: 'Snap transaction submitted',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ });
+}
+
+/**
+ * Track a "Transaction Approved" event when a transaction is approved.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ */
+export async function trackTransactionApproved(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+}): Promise {
+ await trackEvent(TransactionEventType.TransactionApproved, {
+ message: 'Snap transaction approved',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ });
+}
+
+/**
+ * Track a "Transaction Finalized" event when a transaction reaches final state.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ */
+export async function trackTransactionFinalized(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+}): Promise {
+ await trackEvent(TransactionEventType.TransactionFinalized, {
+ message: 'Snap transaction finalized',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ });
+}
+
+/**
+ * Track a "Security Alert Detected" event when a malicious or warning transaction is detected.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ * @param properties.securityAlertResponse - The type of security alert (Warning, Malicious).
+ * @param properties.securityAlertReason - The reason for the security alert.
+ * @param properties.securityAlertDescription - Human-readable description of the alert.
+ */
+export async function trackSecurityAlertDetected(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+ securityAlertResponse: string;
+ securityAlertReason: string | null;
+ securityAlertDescription: string;
+}): Promise {
+ await trackEvent(SecurityEventType.SecurityAlertDetected, {
+ message: 'Snap security alert detected',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ security_alert_response: properties.securityAlertResponse,
+ security_alert_reason: properties.securityAlertReason,
+ security_alert_description: properties.securityAlertDescription,
+ });
+}
+
+/**
+ * Track a "Security Scan Completed" event when a transaction security scan finishes.
+ *
+ * @param properties - Event properties.
+ * @param properties.origin - The origin of the request.
+ * @param properties.accountType - The type of account.
+ * @param properties.chainIdCaip - The CAIP-2 chain ID.
+ * @param properties.scanStatus - The status of the scan (SUCCESS, ERROR).
+ * @param properties.hasSecurityAlerts - Whether security alerts were detected.
+ */
+export async function trackSecurityScanCompleted(properties: {
+ origin: string;
+ accountType: string;
+ chainIdCaip: string;
+ scanStatus: string;
+ hasSecurityAlerts: boolean;
+}): Promise {
+ await trackEvent(SecurityEventType.SecurityScanCompleted, {
+ message: 'Snap security scan completed',
+ origin: properties.origin,
+ account_type: properties.accountType,
+ chain_id_caip: properties.chainIdCaip,
+ scan_status: properties.scanStatus,
+ has_security_alerts: properties.hasSecurityAlerts,
+ });
+}
+
+/**
+ * Track an error in MetaMask via Sentry (`snap_trackError`).
+ *
+ * RPC failures are caught and logged but never rethrown, so this is
+ * safe to call from already-failing error-handling paths without risk
+ * of masking the original failure.
+ *
+ * @param error - The error to report to Sentry.
+ * @returns The Sentry event ID on success, or `undefined` on failure.
+ */
+export async function trackError(
+ error: Error | unknown,
+): Promise {
+ try {
+ let errorToTrack = error;
+
+ if (!(error instanceof Error)) {
+ errorToTrack = ensureError(error);
+ }
+ return await getSnapProvider().request({
+ method: 'snap_trackError',
+ params: { error: getJsonError(errorToTrack) },
+ });
+ } catch (rpcError) {
+ logger.warn({ rpcError }, 'Failed to track error via snap_trackError');
+ return undefined;
+ }
+}
diff --git a/merged-packages/stellar-wallet-snap/tsconfig.json b/merged-packages/stellar-wallet-snap/tsconfig.json
new file mode 100644
index 00000000..cfb11651
--- /dev/null
+++ b/merged-packages/stellar-wallet-snap/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "module": "CommonJS",
+ "moduleResolution": "node",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noErrorTruncation": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "@metamask/snaps-sdk",
+ "exactOptionalPropertyTypes": false,
+ "types": ["jest"],
+ "esModuleInterop": true,
+ "noEmit": true,
+ "strict": true
+ },
+ "include": ["**/*.ts", "**/*.tsx", "locales/*.json"]
+}