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 @@ +Stellar 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"] +}