diff --git a/merged-packages/solana-wallet-snap/.env.sample b/merged-packages/solana-wallet-snap/.env.sample new file mode 100644 index 00000000..6896527c --- /dev/null +++ b/merged-packages/solana-wallet-snap/.env.sample @@ -0,0 +1,27 @@ +# Use: +# - local for local development +# - test for running tests locally (mandatory) +# - production before submitting a PR +ENVIRONMENT= + +# List of RPC URLs for each network. +# The first one in the list is the primary one, the others are fallbacks. +RPC_URL_MAINNET_LIST= +RPC_URL_DEVNET_LIST= +RPC_URL_TESTNET_LIST= +RPC_URL_LOCALNET_LIST= + +RPC_WEB_SOCKET_URL_MAINNET= +RPC_WEB_SOCKET_URL_DEVNET= +RPC_WEB_SOCKET_URL_TESTNET= +RPC_WEB_SOCKET_URL_LOCALNET= + +EXPLORER_BASE_URL= + +PRICE_API_BASE_URL= +TOKEN_API_BASE_URL= +STATIC_API_BASE_URL= +SECURITY_ALERTS_API_BASE_URL= +NFT_API_BASE_URL= + +LOCAL_API_BASE_URL= diff --git a/merged-packages/solana-wallet-snap/CHANGELOG.md b/merged-packages/solana-wallet-snap/CHANGELOG.md new file mode 100644 index 00000000..32a43b65 --- /dev/null +++ b/merged-packages/solana-wallet-snap/CHANGELOG.md @@ -0,0 +1,1234 @@ +# 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] + +### Changed + +- **BREAKING:** Reconcile snap package with monorepo template ([#628](https://github.com/MetaMask/snap-solana-wallet/pull/628)) + - Require Node.js `>=20` + +## [3.0.0] + +### Added + +- Implement keyring type and methods to V2 ([#606](https://github.com/MetaMask/snap-solana-wallet/pull/606)) + - Implements `getAccounts` and `exportAccount`. + - Defines loosened keyring type that omits irrelevant properties from the keyring V2 type. + +### Changed + +- Bump `@metamask/keyring-api` to `^23.5.0` and `@metamask/keyring-snap-sdk` to `^9.2.0` ([#606](https://github.com/MetaMask/snap-solana-wallet/pull/606)) + +### Removed + +- **BREAKING:** Remove v1-only keyring methods now that the Snap speaks the keyring V2 protocol ([#606](https://github.com/MetaMask/snap-solana-wallet/pull/606)) + - Removes `createAccount` (superseded by `createAccounts`), `listAccounts` (superseded by `getAccounts`), `updateAccount`, and `filterAccountChains`. + - Stops emitting the `AccountCreated` and `AccountDeleted` keyring events; account lifecycle is now driven by the V2 client via `createAccounts`/`deleteAccount` return values. + +## [2.10.0] + +### Changed + +- Track suppressed errors ([#624](https://github.com/MetaMask/snap-solana-wallet/pull/624)) + +## [2.9.1] + +### Fixed + +- Return the `signProofOfOwnership` signature as 0x-prefixed hex instead of base58 ([#617](https://github.com/MetaMask/snap-solana-wallet/pull/617)) + +## [2.9.0] + +### Added + +- Add `signProofOfOwnership` client request method ([#611](https://github.com/MetaMask/snap-solana-wallet/pull/611)) + - This method silently signs `metamask:proof-of-ownership::
` messages with the account's ed25519 key + +### Changed + +- Validate that account IDs passed to `keyring_setSelectedAccounts` belong to the snap, rejecting unknown IDs with `InvalidParamsError` ([#604](https://github.com/MetaMask/snap-solana-wallet/pull/604)) +- **BREAKING:** `getAccount` now throws on an invalid id instead of returning `undefined` ([#606](https://github.com/MetaMask/snap-solana-wallet/pull/606)) +- Wrap all public keyring method errors in a single `SnapError` ([#606](https://github.com/MetaMask/snap-solana-wallet/pull/606)) + +## [2.8.0] + +### Added + +- Add support for `keyring_createAccounts` method to enable batch account creation ([#599](https://github.com/MetaMask/snap-solana-wallet/pull/599)) + +## [2.7.4] + +### Changed + +- Replace card amount approval method to be indexable by our provider Baanx ([#593](https://github.com/MetaMask/snap-solana-wallet/pull/593)) +- Implement card amount approval method expiration validation ([#594](https://github.com/MetaMask/snap-solana-wallet/pull/594)) + +### Fixed + +- Solana transaction confirmation screen issues ([#595](https://github.com/MetaMask/snap-solana-wallet/pull/595)) + - Display recipient address on confirmation screen + - Hide "Request from MetaMask" for user-initiated transactions + - Fix "You receive" flash during loading +- Emit missing "Transaction Submitted" event at broadcast time ([#598](https://github.com/MetaMask/snap-solana-wallet/pull/598)) + +## [2.7.3] + +### Changed + +- Increase compute unit limit for transactions that handle approval of card amounts ([#587](https://github.com/MetaMask/snap-solana-wallet/pull/587)) +- Remove convertion layer for images displayed in Snaps UI ([#577](https://github.com/MetaMask/snap-solana-wallet/pull/577)) + +### Fixed + +- Make sure an interface exists when accessing its context ([#586](https://github.com/MetaMask/snap-solana-wallet/pull/586)) + +## [2.7.2] + +### Changed + +- Add `getOrCreateAssociatedTokenAccount` instruction before `approve` when handling approval of card amounts ([#584](https://github.com/MetaMask/snap-solana-wallet/pull/584)) + +## [2.7.1] + +### Changed + +- Ensure snap state consistency if account creation fails on the client's side ([#557](https://github.com/MetaMask/snap-solana-wallet/pull/557)) + +## [2.7.0] + +### Changed + +- Use `Approve` chain instructions instead of `ApproveChecked` on the `approveCardAmount` client request hook ([#580](https://github.com/MetaMask/snap-solana-wallet/pull/580)) + +## [2.6.2] + +### Fixed + +- Card methods improvements ([#578](https://github.com/MetaMask/snap-solana-wallet/pull/578)) +- Fully deprecate `@solana/web3.js` on the project ([#575](https://github.com/MetaMask/snap-solana-wallet/pull/575)) + +## [2.6.1] + +### Fixed + +- Fix token 2022 send ([#573](https://github.com/MetaMask/snap-solana-wallet/pull/573)) +- Unblock `onProtocolRequest` ([#572](https://github.com/MetaMask/snap-solana-wallet/pull/572)) +- Clear stored interface ID after the interface is closed ([#571](https://github.com/MetaMask/snap-solana-wallet/pull/571)) + +## [2.6.0] + +### Added + +- Card spending methods ([#569](https://github.com/MetaMask/snap-solana-wallet/pull/569)) + +## [2.5.1] + +### Fixed + +- Prevent big images from exceeding snap context size ([#567](https://github.com/MetaMask/snap-solana-wallet/pull/567)) + +## [2.5.0] + +### Changed + +- Ensure only safe concurrent state operations ([#564](https://github.com/MetaMask/snap-solana-wallet/pull/564)) + +## [2.4.8] + +### Fixed + +- Use mutex for state blob modifications ([#562](https://github.com/MetaMask/snap-solana-wallet/pull/562)) +- Use `snap_getClientStatus` for `clientVersion` ([#560](https://github.com/MetaMask/snap-solana-wallet/pull/560)) +- Publish icon to NPM ([#561](https://github.com/MetaMask/snap-solana-wallet/pull/561)) + +## [2.4.7] + +### Fixed + +- Fix `getAccount` and `listAccounts` ([#558](https://github.com/MetaMask/snap-solana-wallet/pull/558)) + +## [2.4.6] + +### Changed + +- Adapted the payload of request `signRewardsMessage` for improved performance ([#554](https://github.com/MetaMask/snap-solana-wallet/pull/554)) + +## [2.4.5] + +### Fixed + +- Improved speed when lookin up the state for accounts by id ([#550](https://github.com/MetaMask/snap-solana-wallet/pull/550)) +- Fixed a serialization issue causing the snap to try rendering bigints ([#551](https://github.com/MetaMask/snap-solana-wallet/pull/551)) + +## [2.4.4] + +### Fixed + +- Properly build the default icon url ([#548](https://github.com/MetaMask/snap-solana-wallet/pull/548)) +- Fix the send flow to support token account recipients ([#547](https://github.com/MetaMask/snap-solana-wallet/pull/547)) + +## [2.4.3] + +### Changed + +- Optimize account subscriptions to only monitor selected accounts ([#543](https://github.com/MetaMask/snap-solana-wallet/pull/543)) +- Skip full account sync after account creation for improved performance ([#543](https://github.com/MetaMask/snap-solana-wallet/pull/543)) +- Add caching for PriceAPI exchange rate requests ([#545](https://github.com/MetaMask/snap-solana-wallet/pull/545)) +- Implement on-demand SNS domain resolution in confirmation screens ([#544](https://github.com/MetaMask/snap-solana-wallet/pull/544)) + +## [2.4.2] + +### Changed + +- Enable `devnet` only for flask ([#541](https://github.com/MetaMask/snap-solana-wallet/pull/541)) + +## [2.4.1] + +### Fixed + +- Removed the 1 minute validation on rewards message timestamp ([#539](https://github.com/MetaMask/snap-solana-wallet/pull/539)) + +## [2.4.0] + +### Changed + +- Enable Devnet support across our environments ([#526](https://github.com/MetaMask/snap-solana-wallet/pull/526)) + +## [2.3.11] + +### Changed + +- Offline fee calculation with support to secp256k1 and secp256r1 signatures ([#527](https://github.com/MetaMask/snap-solana-wallet/pull/527)) + +### Fixed + +- Added support for signing transactions where the fee payer differs from the user's account ([#535](https://github.com/MetaMask/snap-solana-wallet/pull/535)) + +## [2.3.10] + +### Added + +- Mechanism to time out websocket subscriptions ([#525](https://github.com/MetaMask/snap-solana-wallet/pull/525)) + +### Changed + +- Offloaded the assets and transactions sync from account creation ([#528](https://github.com/MetaMask/snap-solana-wallet/pull/528)) + +### Fixed + +- Enforce proper assets state sync between snap and client ([#529](https://github.com/MetaMask/snap-solana-wallet/pull/529)) + +## [2.3.9] + +### Added + +- Add new client-only entrypoint `signRewardsMessage` ([#522](https://github.com/MetaMask/snap-solana-wallet/pull/522)) +- Track Sentry events for unclean WebSocket connection closures ([#523](https://github.com/MetaMask/snap-solana-wallet/pull/523)) + +### Fixed + +- Fix validation for .sol domain name resolution ([#521](https://github.com/MetaMask/snap-solana-wallet/pull/521)) + +## [2.3.8] + +### Fixed + +- Notify client when native SOL balance reaches zero ([#519](https://github.com/MetaMask/snap-solana-wallet/pull/519)) + +## [2.3.7] + +### Added + +- Support tokens with multiplier ([#509](https://github.com/MetaMask/snap-solana-wallet/pull/509)) + +### Fixed + +- Hide spam transactions from activity list ([#515](https://github.com/MetaMask/snap-solana-wallet/pull/515)) +- Default to symbol `UNKNOWN` in activity for tokens with no metadata ([#517](https://github.com/MetaMask/snap-solana-wallet/pull/517)) + +## [2.3.6] + +### Changed + +- Remove unnecessary analytics property ([#513](https://github.com/MetaMask/snap-solana-wallet/pull/513)) + +## [2.3.5] + +### Added + +- Instrument create account ([#510](https://github.com/MetaMask/snap-solana-wallet/pull/510)) + +### Changed + +- Update analytics properties ([#511](https://github.com/MetaMask/snap-solana-wallet/pull/511)) + +## [2.3.4] + +### Fixed + +- Fix the send transaction object passed to blockaid scan API ([#507](https://github.com/MetaMask/snap-solana-wallet/pull/507)) + +## [2.3.3] + +### Changed + +- Close WebSockets onInactive after a grace period ([#505](https://github.com/MetaMask/snap-solana-wallet/pull/505)) +- Use safer `TransferChecked` instruction instead `Transfer` to send SPL tokens ([#503](https://github.com/MetaMask/snap-solana-wallet/pull/503)) + +### Fixed + +- Fix the parsing of self transfers ([#504](https://github.com/MetaMask/snap-solana-wallet/pull/504)) + +## [2.3.2] + +### Fixed + +- When the client starts/updates, only open WebScoket connections if the client is active ([#500](https://github.com/MetaMask/snap-solana-wallet/pull/500)) + +## [2.3.1] + +### Fixed + +- Close all WebSocket connections when the client becomes inactive ([#498](https://github.com/MetaMask/snap-solana-wallet/pull/498)) + +## [2.3.0] + +### Added + +- Added new swap/bridge `onClientRequest` methods ([#496](https://github.com/MetaMask/snap-solana-wallet/pull/496)) + +### Changed + +- Refactor `onConfirmSend` to `confirmSend` ([#496](https://github.com/MetaMask/snap-solana-wallet/pull/496)) + +## [2.2.0] + +### Added + +- Unified send flow methods (`onConfirmSend`, `onAddressInput`, `onAmountInput`) ([#494](https://github.com/MetaMask/snap-solana-wallet/pull/494)) + +## [2.1.4] + +### Changed + +- Enhance performance by triggering UI refresh jobs only when the UI is open ([#490](https://github.com/MetaMask/snap-solana-wallet/pull/490)) + +### Fixed + +- Increase the compute unit limit used for sending SPL tokens ([#492](https://github.com/MetaMask/snap-solana-wallet/pull/492)) +- Ensure WebSocket connections are open whenever the client becomes active ([#489](https://github.com/MetaMask/snap-solana-wallet/pull/489)) + +## [2.1.3] + +### Fixed + +- Fixed changelog syntax ([#487](https://github.com/MetaMask/snap-solana-wallet/pull/487)) + +## [2.1.2] + +### Changed + +- Add logging ([#484](https://github.com/MetaMask/snap-solana-wallet/pull/484)) + +### Fixed + +- Fixed changelog syntax ([#483](https://github.com/MetaMask/snap-solana-wallet/pull/483)) + +## [2.1.1] + +### Changed + +- Assets are now fetched and saved immediately when creating an account since they're needed by the client right away ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) +- Transaction fetching has been moved to a background event since transactions aren't immediately needed by the client ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) +- Added a 20-transaction limit to background transaction fetching to improve performance ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) + +### Fixed + +- Fix native asset extraction in `onAssetsLookup` ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) +- Remove token assets with zero balance from `Keyring.listAccountAssets` and `Keyring.getAccountBalances` responses while preserving zero-balance native assets ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) +- Ensure we always return at least the native asset with zero balance ([#481](https://github.com/MetaMask/snap-solana-wallet/pull/481)) + +## [2.1.0] + +### Changed + +- Asset storage refactor and performance improvements ([#479](https://github.com/MetaMask/snap-solana-wallet/pull/479)) + +### Fixed + +- Automatically remove assets from the list when their balance reaches zero ([#479](https://github.com/MetaMask/snap-solana-wallet/pull/479)) + +### Security + +- Excessive data access and prolonged sensitive information exposure ([#478](https://github.com/MetaMask/snap-solana-wallet/pull/478)) +- Add sanitization and prevents control character injection ([#472](https://github.com/MetaMask/snap-solana-wallet/pull/472)) + +## [2.0.0] + +### Added + +- Track inactive wss messages + removing account HTTP sync cronjobs ([#476](https://github.com/MetaMask/snap-solana-wallet/pull/476)) +- Update balances & transactions when new token is received ([#469](https://github.com/MetaMask/snap-solana-wallet/pull/469)) +- Overwrite `skipPreflight` if comes from request ([#463](https://github.com/MetaMask/snap-solana-wallet/pull/463)) +- Show SNS domains on UI ([#462](https://github.com/MetaMask/snap-solana-wallet/pull/462)) +- Detect WSS receive transactions ([#461](https://github.com/MetaMask/snap-solana-wallet/pull/461)) +- Adding `AccountSelector` component to send ([#445](https://github.com/MetaMask/snap-solana-wallet/pull/445)) +- WSS unsubscribe from deleted account ([#458](https://github.com/MetaMask/snap-solana-wallet/pull/458)) +- Migrate accounts sync from HTTP polling to Websockets ([#453](https://github.com/MetaMask/snap-solana-wallet/pull/453)) +- Support NFT assets ([#422](https://github.com/MetaMask/snap-solana-wallet/pull/422)) + +### Changed + +- Rework Sync accounts ([#475](https://github.com/MetaMask/snap-solana-wallet/pull/475)) +- Replace `console` with `logger` ([#464](https://github.com/MetaMask/snap-solana-wallet/pull/464)) +- Move WSS monitoring logic out of `AssetsService` ([#460](https://github.com/MetaMask/snap-solana-wallet/pull/460)) +- Adds `errorTrackingTransport` ([#456](https://github.com/MetaMask/snap-solana-wallet/pull/456)) +- Populate asset units after mapping ([#459](https://github.com/MetaMask/snap-solana-wallet/pull/459)) +- Temporarily disable NFT assets fetching ([#457](https://github.com/MetaMask/snap-solana-wallet/pull/457)) + +### Fixed + +- Adds check and throws error for `buildUrl` ([#471](https://github.com/MetaMask/snap-solana-wallet/pull/471)) +- Validate address for Signin ([#468](https://github.com/MetaMask/snap-solana-wallet/pull/468)) +- WSS disconnection handling ([#467](https://github.com/MetaMask/snap-solana-wallet/pull/467)) +- `buildUrl` function with potential vulnerability ([#466](https://github.com/MetaMask/snap-solana-wallet/pull/466)) +- Validate origin in method `onProtocolRequest` ([#465](https://github.com/MetaMask/snap-solana-wallet/pull/465)) + +## [1.36.0] + +### Changed + +- The flow signAndSendTransaction now relies on WebSockets to receive confirmation updates, instead of HTTP polling ([#450](https://github.com/MetaMask/snap-solana-wallet/pull/450)) +- The flow signTransaction now relies on WebSockets to receive confirmation updates, instead of HTTP polling ([#452](https://github.com/MetaMask/snap-solana-wallet/pull/452)) +- Simplify some logic in the transaction mapping function ([#451](https://github.com/MetaMask/snap-solana-wallet/pull/451)) + +## [1.35.2] + +### Fixed + +- Return correct structure on the `onAssetsMarketData` handler ([#448](https://github.com/MetaMask/snap-solana-wallet/pull/448)) + +## [1.35.1] + +### Added + +- Implement Solana Name Service support ([#441](https://github.com/MetaMask/snap-solana-wallet/pull/441)) +- Track Blockaid metrics ([#439](https://github.com/MetaMask/snap-solana-wallet/pull/439)) + +### Fixed + +- Correctly export handler `onAssetsMarketData` ([#446](https://github.com/MetaMask/snap-solana-wallet/pull/446)) + +## [1.35.0] + +### Added + +- `onAssetsMarketData` handler ([#426](https://github.com/MetaMask/snap-solana-wallet/pull/426)) +- Websockets setup ([#431](https://github.com/MetaMask/snap-solana-wallet/pull/431)) + +### Changed + +- Move adapters to services and rename them ([#438](https://github.com/MetaMask/snap-solana-wallet/pull/438)) + +## [1.34.0] + +### Changed + +- Switch to duration cronjobs ([#435](https://github.com/MetaMask/snap-solana-wallet/pull/435)) + +### Fixed + +- Validate same origin domain for `signIn` ([#434](https://github.com/MetaMask/snap-solana-wallet/pull/434)) + +## [1.33.4] + +### Fixed + +- Handle errors on the edge ([#430](https://github.com/MetaMask/snap-solana-wallet/pull/430)) + +## [1.33.3] + +### Changed + +- Use `InMemoryCache` in `PriceApiService` ([#427](https://github.com/MetaMask/snap-solana-wallet/pull/427)) + +## [1.33.2] + +### Added + +- Adds `snap_trackEvent` and `origin` param ([#418](https://github.com/MetaMask/snap-solana-wallet/pull/418)) + +### Fixed + +- Confirmation title aligned with method request ([#423](https://github.com/MetaMask/snap-solana-wallet/pull/423)) + +## [1.33.1] + +### Fixed + +- Max button not working when balace is lower than rent + fee ([#419](https://github.com/MetaMask/snap-solana-wallet/pull/419)) +- Transaction scan when api fails ([#419](https://github.com/MetaMask/snap-solana-wallet/pull/419)) + +## [1.33.0] + +### Added + +- `onClientRequest` implementation ([#405](https://github.com/MetaMask/snap-solana-wallet/pull/405)) +- Instructions improvements in confirmation screen ([#416](https://github.com/MetaMask/snap-solana-wallet/pull/416)) +- Cover `onAccountsRefresh` with unit tests ([#415](https://github.com/MetaMask/snap-solana-wallet/pull/415)) + +### Changed + +- Move `polyfills` to `infrastructure` directory ([#414](https://github.com/MetaMask/snap-solana-wallet/pull/414)) +- Move Solana Keyring account to `domain` layer ([#412](https://github.com/MetaMask/snap-solana-wallet/pull/412)) + +## [1.32.0] + +### Added + +- Pass origin param to Blockaid ([#395](https://github.com/MetaMask/snap-solana-wallet/pull/395)) +- Display origin param in confirmation screens ([#408](https://github.com/MetaMask/snap-solana-wallet/pull/408)) +- Add Swaps failling test case where we end up with only positive balance changes ([#398](https://github.com/MetaMask/snap-solana-wallet/pull/398)) + +### Fixed + +- Spam filter was not triggered on failed transactions ([#399](https://github.com/MetaMask/snap-solana-wallet/pull/399)) +- Add space to "Advanced details" button ([#407](https://github.com/MetaMask/snap-solana-wallet/pull/407)) +- Replace viewbox on question mark image ([#404](https://github.com/MetaMask/snap-solana-wallet/pull/404)) +- Fix `onProtocolRequest` validation ([#403](https://github.com/MetaMask/snap-solana-wallet/pull/403)) + +## [1.31.2] + +### Changed + +- Transaction scan messages ([#401](https://github.com/MetaMask/snap-solana-wallet/pull/401)) +- Disable continue button on scan error ([#400](https://github.com/MetaMask/snap-solana-wallet/pull/400)) +- Resize unkown svg image ([#397](https://github.com/MetaMask/snap-solana-wallet/pull/397)) +- Improve scan messages ([#367](https://github.com/MetaMask/snap-solana-wallet/pull/367)) + +## [1.31.1] + +### Fixed + +- Remove lifecycle update ([#393](https://github.com/MetaMask/snap-solana-wallet/pull/393)) +- Match storage limit with expected page size for transactions coming from the snap ([#392](https://github.com/MetaMask/snap-solana-wallet/pull/392)) + +## [1.31.0] + +### Added + +- Enable `getMinimumBalanceForRentExemption` ([#360](https://github.com/MetaMask/snap-solana-wallet/pull/360)) +- New translations ([#348](https://github.com/MetaMask/snap-solana-wallet/pull/348)) + +### Changed + +- Restore max button in send flow ([#384](https://github.com/MetaMask/snap-solana-wallet/pull/384)) +- Remove additional get context on refresh send ([#385](https://github.com/MetaMask/snap-solana-wallet/pull/385)) +- Clean up `AssetService` ([#380](https://github.com/MetaMask/snap-solana-wallet/pull/380)) + +### Fixed + +- Decrease non-active cronjob time to 30min ([#390](https://github.com/MetaMask/snap-solana-wallet/pull/390)) +- Prevent inactive client from executing cronjobs ([#388](https://github.com/MetaMask/snap-solana-wallet/pull/388)) +- Prevent empty accounts from refreshing ([#387](https://github.com/MetaMask/snap-solana-wallet/pull/387)) +- Fix number formatting when locale is composed ([#382](https://github.com/MetaMask/snap-solana-wallet/pull/382)) +- Changing asset in the Send flow disabled Continue button ([#374](https://github.com/MetaMask/snap-solana-wallet/pull/374)) + +## [1.30.4] + +### Fixed + +- Spread cronjob load time randomly per user ([#379](https://github.com/MetaMask/snap-solana-wallet/pull/379)) + +## [1.30.3] + +### Fixed + +- Rollback send state ([#375](https://github.com/MetaMask/snap-solana-wallet/pull/375)) + +## [1.30.2] + +### Fixed + +- Hotfix: get balances ([#372](https://github.com/MetaMask/snap-solana-wallet/pull/372)) +- Add spam failed transaction case to the mapping tests ([#371](https://github.com/MetaMask/snap-solana-wallet/pull/371)) + +## [1.30.1] + +### Fixed + +- Rollback `fungible` check ([#369](https://github.com/MetaMask/snap-solana-wallet/pull/369)) + +## [1.30.0] + +### Changed + +- Replace `refreshAssets` and `refreshTransactions` with `refreshAccounts` ([#365](https://github.com/MetaMask/snap-solana-wallet/pull/365)) +- Optimize get account balances ([#364](https://github.com/MetaMask/snap-solana-wallet/pull/364)) + +### Fixed + +- Set correct fungible flag using quick check ([#363](https://github.com/MetaMask/snap-solana-wallet/pull/363)) + +## [1.29.0] + +### Changed + +- `snap_manageState` to `snap_setState` ([#359](https://github.com/MetaMask/snap-solana-wallet/pull/359)) + +### Fixed + +- Transaction confirmation loading button while fetching icon ([#361](https://github.com/MetaMask/snap-solana-wallet/pull/361)) + +## [1.28.3] + +### Fixed + +- More transaction mapping inaccuracies ([#349](https://github.com/MetaMask/snap-solana-wallet/pull/349)) +- Temporarily hide "max"' button in send form ([#356](https://github.com/MetaMask/snap-solana-wallet/pull/356)) + +## [1.28.2] + +### Fixed + +- Block to address until data is ready on send ([#354](https://github.com/MetaMask/snap-solana-wallet/pull/354)) + +## [1.28.1] + +### Changed + +- Simplify mapping fee ([#347](https://github.com/MetaMask/snap-solana-wallet/pull/347)) + +### Fixed + +- Send form address input not triggering amount pickup ([#352](https://github.com/MetaMask/snap-solana-wallet/pull/352)) +- Fully respect `activeNetworks` set in the ConfigProvider ([#351](https://github.com/MetaMask/snap-solana-wallet/pull/351)) +- `createAccount` idempotency ([#350](https://github.com/MetaMask/snap-solana-wallet/pull/350)) + +## [1.28.0] + +### Added + +- New translations ([#344](https://github.com/MetaMask/snap-solana-wallet/pull/344)) + +### Fixed + +- Disabled Devnet ([#343](https://github.com/MetaMask/snap-solana-wallet/pull/343)) +- Transaction mapping ([#343](https://github.com/MetaMask/snap-solana-wallet/pull/343)) + +## [1.27.0] + +### Fixed + +- Decrease cache time to a minute ([#341](https://github.com/MetaMask/snap-solana-wallet/pull/341)) + +## [1.26.1] + +### Fixed + +- Transaction after interaction ([#339](https://github.com/MetaMask/snap-solana-wallet/pull/339)) +- Make `marketCap` optional ([#338](https://github.com/MetaMask/snap-solana-wallet/pull/338)) + +## [1.26.0] + +### Added + +- Integrate `AssetSelector` in Send ([#336](https://github.com/MetaMask/snap-solana-wallet/pull/336)) + +### Fixed + +- Provide skeleton width ([#335](https://github.com/MetaMask/snap-solana-wallet/pull/335)) +- Add "all" time range to historical prices ([#333](https://github.com/MetaMask/snap-solana-wallet/pull/333)) + +## [1.25.1] + +### Fixed + +- Bring back clear button to `ToAddress` ([#331](https://github.com/MetaMask/snap-solana-wallet/pull/331)) + +## [1.25.0] + +### Added + +- Add transactions scanning after signing a transaction in case it gets broadcast by dApps ([#324](https://github.com/MetaMask/snap-solana-wallet/pull/324)) +- Hide spam transactions with status `failed` ([#322](https://github.com/MetaMask/snap-solana-wallet/pull/322)) + +### Changed + +- Improved typing performance ([#321](https://github.com/MetaMask/snap-solana-wallet/pull/321)) +- Move accounts to non-encrypted state ([#326](https://github.com/MetaMask/snap-solana-wallet/pull/326)) +- Simplify SPL Tokens send instructions ([#323](https://github.com/MetaMask/snap-solana-wallet/pull/323)) + +### Fixed + +- Filter out assets received by other addresses when a transaction is a Receive ([#325](https://github.com/MetaMask/snap-solana-wallet/pull/325)) + +## [1.24.0] + +### Added + +- Automatically hide spam tokens from the transaction history ([#317](https://github.com/MetaMask/snap-solana-wallet/pull/317)) + +### Changed + +- Add transport without `x-bigtable` ([#318](https://github.com/MetaMask/snap-solana-wallet/pull/318)) + +### Fixed + +- Pass request options in solana wallet methods ([#315](https://github.com/MetaMask/snap-solana-wallet/pull/315)) + +## [1.23.0] + +### Added + +- Account creation with configurable derivation path ([#311](https://github.com/MetaMask/snap-solana-wallet/pull/311)) + +### Fixed + +- Throw appropriate error when user rejects a request ([#312](https://github.com/MetaMask/snap-solana-wallet/pull/312)) +- Error message not formatted properly when cancelling a transaction from a dApp ([#309](https://github.com/MetaMask/snap-solana-wallet/pull/309)) + +## [1.22.0] + +### Added + +- Account creation is now idempotent ([#306](https://github.com/MetaMask/snap-solana-wallet/pull/306)) + +### Fixed + +- Use the proper entropy when signing transactions ([#305](https://github.com/MetaMask/snap-solana-wallet/pull/305)) +- Properly export the handler `onAssetHistoricalPrice` ([#305](https://github.com/MetaMask/snap-solana-wallet/pull/305)) + +## [1.21.0] + +### Added + +- Support sending Token2022 SPL tokens ([#285](https://github.com/MetaMask/snap-solana-wallet/pull/285)) +- Support `onAssetHistoricalPrice` to return price chart ([#300](https://github.com/MetaMask/snap-solana-wallet/pull/300)) + +### Changed + +- Make `entropySource` optional when creating a Solana account ([#299](https://github.com/MetaMask/snap-solana-wallet/pull/299)) + +### Fixed + +- Simplify caching ([#302](https://github.com/MetaMask/snap-solana-wallet/pull/302)) +- Validate that SOL balance is > 0 to cover transaction fees before simulation ([#303](https://github.com/MetaMask/snap-solana-wallet/pull/303)) + +## [1.20.0] + +### Added + +- `onAssetsConversion` now suports market data ([#291](https://github.com/MetaMask/snap-solana-wallet/pull/291)) +- Add header for RPC latency ([#295](https://github.com/MetaMask/snap-solana-wallet/pull/295)) + +### Fixed + +- Support estimating compute units limit for tailing transactions ([#297](https://github.com/MetaMask/snap-solana-wallet/pull/297)) + +## [1.19.0] + +### Added + +- Automatically add priority fee and compute budget instructions ([#290](https://github.com/MetaMask/snap-solana-wallet/pull/290), [#288](https://github.com/MetaMask/snap-solana-wallet/pull/288)) +- Map `TransferChecked` ([#280](https://github.com/MetaMask/snap-solana-wallet/pull/280)) +- Include market data when fetching spot prices ([#272](https://github.com/MetaMask/snap-solana-wallet/pull/272)) + +### Changed + +- Bump keyring api + map unknown tx type ([#292](https://github.com/MetaMask/snap-solana-wallet/pull/292)) +- Alternative background in SendForm ([#289](https://github.com/MetaMask/snap-solana-wallet/pull/289)) +- Transaction `skipPreflight` ([#287](https://github.com/MetaMask/snap-solana-wallet/pull/287)) + +### Fixed + +- Error message for account creation ([#286](https://github.com/MetaMask/snap-solana-wallet/pull/286)) + +## [1.18.1] + +### Changed + +- Make state support non-json-serializable data ([#283](https://github.com/MetaMask/snap-solana-wallet/pull/283)) + +### Removed + +- Remove the unnecessary `PositiveNumberStruct` ([#278](https://github.com/MetaMask/snap-solana-wallet/pull/278)) + +### Fixed + +- `getLowestUnusedIndex` should consider SRPs in the check ([#282](https://github.com/MetaMask/snap-solana-wallet/pull/282)) +- Prevent rounding errors during validation of amount ([#281](https://github.com/MetaMask/snap-solana-wallet/pull/281)) + +## [1.18.0] + +### Added + +- Keyring `discoverAccounts` ([#274](https://github.com/MetaMask/snap-solana-wallet/pull/274)) +- Support for `Token2022` program assets ([#275](https://github.com/MetaMask/snap-solana-wallet/pull/275)) + +### Fixed + +- Partially sign transaction ([#276](https://github.com/MetaMask/snap-solana-wallet/pull/276)) + +## [1.17.0] + +### Added + +- Add `getLatestBlockhash` protocol request ([#271](https://github.com/MetaMask/snap-solana-wallet/pull/271)) +- Add optional `assetId` to send flow ([#270](https://github.com/MetaMask/snap-solana-wallet/pull/270)) + +## [1.16.1] + +### Fixed + +- `signIn` message formatting ([#268](https://github.com/MetaMask/snap-solana-wallet/pull/268)) + +## [1.16.0] + +### Added + +- Forward `MetaMaskOptions` in `keyring_createAccount` method ([#266](https://github.com/MetaMask/snap-solana-wallet/pull/266)) + +## [1.15.1] + +### Changed + +- Move to solscan ([#264](https://github.com/MetaMask/snap-solana-wallet/pull/264)) + +## [1.15.0] + +### Added + +- Use new snap MetaMask preferences ([#258](https://github.com/MetaMask/snap-solana-wallet/pull/258)) + +### Changed + +- Move state to unencrypted ([#261](https://github.com/MetaMask/snap-solana-wallet/pull/261)) +- Upgrade `@solana/web3.js` to `@solana/kit` ([#259](https://github.com/MetaMask/snap-solana-wallet/pull/259)) + +### Fixed + +- Transaction decoding for `Message` and `Transaction` ([#262](https://github.com/MetaMask/snap-solana-wallet/pull/262)) + +## [1.14.0] + +### Added + +- New transaltions ([#257](https://github.com/MetaMask/snap-solana-wallet/pull/257)) +- Warn on bad account in confirm `signIn` ([#256](https://github.com/MetaMask/snap-solana-wallet/pull/256)) +- Confirmation UI for `signTransaction` ([#251](https://github.com/MetaMask/snap-solana-wallet/pull/251)) +- Confirmation UI for `signIn` and `signMessage` ([#247](https://github.com/MetaMask/snap-solana-wallet/pull/247)) + +### Fixed + +- Correct expected parameter shape for `resolveAccountAddress` ([#254](https://github.com/MetaMask/snap-solana-wallet/pull/254)) +- Assets wipe up by the cronjob ([#253](https://github.com/MetaMask/snap-solana-wallet/pull/253)) +- Fee estimation in confirm tx request ([#252](https://github.com/MetaMask/snap-solana-wallet/pull/252)) +- Mapping differences between Send, Receive and Swap transactions ([#250](https://github.com/MetaMask/snap-solana-wallet/pull/250)) + +## [1.13.0] + +### Added + +- New translations ([#244](https://github.com/MetaMask/snap-solana-wallet/pull/244)) +- Substract rent from max amount + improve validation on amount f… ([#243](https://github.com/MetaMask/snap-solana-wallet/pull/243)) + +### Fixed + +- Show amount for self and failed transactions ([#240](https://github.com/MetaMask/snap-solana-wallet/pull/240)) +- Transaction updates not showing asset units ([#245](https://github.com/MetaMask/snap-solana-wallet/pull/245)) + +## [1.12.0] + +### Added + +- `Sign{Message/Transaction/In}` backend ([#241](https://github.com/MetaMask/snap-solana-wallet/pull/241)) +- Analytics service ([#236](https://github.com/MetaMask/snap-solana-wallet/pull/236)) + +### Fixed + +- Add validation to `getGenesisHash` request ([#239](https://github.com/MetaMask/snap-solana-wallet/pull/239)) + +## [1.11.0] + +### Added + +- New translations ([#234](https://github.com/MetaMask/snap-solana-wallet/pull/234)) + +### Fixed + +- Reduce send delay ([#233](https://github.com/MetaMask/snap-solana-wallet/pull/233)) +- Put instructions back on confirmation ([#230](https://github.com/MetaMask/snap-solana-wallet/pull/230)) + +## [1.10.1] + +### Added + +- Support for failed transactions ([#224](https://github.com/MetaMask/snap-solana-wallet/pull/224)) + +### Removed + +- Lifecycle hooks permissions ([#226](https://github.com/MetaMask/snap-solana-wallet/pull/226)) + +## [1.10.0] + +### Added + +- Update balances post transaction ([#208](https://github.com/MetaMask/snap-solana-wallet/pull/208)) + +### Fixed + +- Account keeps incrementing after rejecting add account ([#222](https://github.com/MetaMask/snap-solana-wallet/pull/222)) +- Loss of precision on transfer amounts ([#219](https://github.com/MetaMask/snap-solana-wallet/pull/219)) +- Solve race condition when simulating transaction in send form ([#221](https://github.com/MetaMask/snap-solana-wallet/pull/221)) + +## [1.9.0] + +### Added + +- New translations languages ([#126](https://github.com/MetaMask/snap-solana-wallet/pull/126)) +- Support for `swap` transactions ([#214](https://github.com/MetaMask/snap-solana-wallet/pull/214)) +- Support explicit `accountNameSuggestion` on `createAccount` ([#207](https://github.com/MetaMask/snap-solana-wallet/pull/207)) +- Use `minimumBalanceForRentExemption` to send SOL ([#215](https://github.com/MetaMask/snap-solana-wallet/pull/215)) + +### Fixed + +- Allowing accounts to request `submitRequest` methods ([#218](https://github.com/MetaMask/snap-solana-wallet/pull/218)) + +## [1.8.0] + +### Added + +- Pulg send flow to `signAndSendTransaction` ([#212](https://github.com/MetaMask/snap-solana-wallet/pull/212)) +- Add confirmation before submit request ([#209](https://github.com/MetaMask/snap-solana-wallet/pull/209)) +- Add estimated changes to confirmation ([#210](https://github.com/MetaMask/snap-solana-wallet/pull/210)) + +### Fixed + +- Fiat fee in send confirmation ([#211](https://github.com/MetaMask/snap-solana-wallet/pull/211)) +- Multiple send bug fixes ([#200](https://github.com/MetaMask/snap-solana-wallet/pull/200)) + +## [1.7.0] + +### Added + +- Enable SIP-26 `onProtocolRequest` ([#205](https://github.com/MetaMask/snap-solana-wallet/pull/205)) +- Mock `wallet-standard` methods ([#204](https://github.com/MetaMask/snap-solana-wallet/pull/204)) +- Emit keyring event after sending a transaction ([#203](https://github.com/MetaMask/snap-solana-wallet/pull/203)) +- Implement `getFeeForTransaction` RPC request ([#201](https://github.com/MetaMask/snap-solana-wallet/pull/201)) +- Added `sendAndConfirmTransaction` confirmation ([#183](https://github.com/MetaMask/snap-solana-wallet/pull/183)) + +### Changed + +- Update devnet url ([#202](https://github.com/MetaMask/snap-solana-wallet/pull/202)) +- Improve unit tests for execution ([#199](https://github.com/MetaMask/snap-solana-wallet/pull/199)) + +## [1.6.0] + +### Added + +- Support conversions for all supported assets ([#181](https://github.com/MetaMask/snap-solana-wallet/pull/181)) + +### Fixed + +- Remove assets data for deleted accounts ([#196](https://github.com/MetaMask/snap-solana-wallet/pull/196)) +- Concurrent state updates ([#195](https://github.com/MetaMask/snap-solana-wallet/pull/195)) +- Speed up Solana key derivation ([#191](https://github.com/MetaMask/snap-solana-wallet/pull/191)) +- Update returned account ([#193](https://github.com/MetaMask/snap-solana-wallet/pull/193)) + +## [1.5.0] + +### Added + +- Script to dynamically change the manifest file ([#177](https://github.com/MetaMask/snap-solana-wallet/pull/177)) + +### Changed + +- Audit: Use encrypted state to store user information ([#178](https://github.com/MetaMask/snap-solana-wallet/pull/178)) +- Derive Account private keys on demand ([#187](https://github.com/MetaMask/snap-solana-wallet/pull/187)) + +#### Removed + +- `onUpdate` and `onInstall` handlers ([#186](https://github.com/MetaMask/snap-solana-wallet/pull/186)) + +### Fixed + +- Audit: Pin dependency versions ([#189](https://github.com/MetaMask/snap-solana-wallet/pull/189)) and ([#185](https://github.com/MetaMask/snap-solana-wallet/pull/185)) +- Audit: Potential url injections in api calls ([#182](https://github.com/MetaMask/snap-solana-wallet/pull/182)) +- Audit: Unnecessary `tsx` extension on the snap's entry point file ([#180](https://github.com/MetaMask/snap-solana-wallet/pull/180)) +- Audit: LAX or missing runtime input validation ([#179](https://github.com/MetaMask/snap-solana-wallet/pull/179)) +- Bump `keyring-snap-sdk` version to enable the dispatcher ([#188](https://github.com/MetaMask/snap-solana-wallet/pull/188)) + +## [1.4.0] + +### Added + +- Implement `resolveAccountAddress` ([#175](https://github.com/MetaMask/snap-solana-wallet/pull/175)) +- Implement push based transactions list ([#157](https://github.com/MetaMask/snap-solana-wallet/pull/157)) +- Parity with compiled transactions ([#169](https://github.com/MetaMask/snap-solana-wallet/pull/169)) + +### Changed + +- Update on snap permissions ([#172](https://github.com/MetaMask/snap-solana-wallet/pull/172)) +- Improve env ([#171](https://github.com/MetaMask/snap-solana-wallet/pull/171)) +- Transaction simulation to input change ([#168](https://github.com/MetaMask/snap-solana-wallet/pull/168)) + +### Fixed + +- Transactions data flow problems ([#174](https://github.com/MetaMask/snap-solana-wallet/pull/174)) +- Latency on send flow updates ([#173](https://github.com/MetaMask/snap-solana-wallet/pull/173)) +- Conversions currency to lowercase ([#167](https://github.com/MetaMask/snap-solana-wallet/pull/167)) +- Disable buttons until prices and balances are available ([#166](https://github.com/MetaMask/snap-solana-wallet/pull/166)) + +## [1.3.0] + +### Added + +- Integrate platform apis ([#162](https://github.com/MetaMask/snap-solana-wallet/pull/162)) +- Hide network fee price when no prices ([#164](https://github.com/MetaMask/snap-solana-wallet/pull/164)) +- Can send tokens even when price fetch fails ([#161](https://github.com/MetaMask/snap-solana-wallet/pull/161)) +- SIP-29 handlers ([#159](https://github.com/MetaMask/snap-solana-wallet/pull/159)) +- onInstall handler ([#145](https://github.com/MetaMask/snap-solana-wallet/pull/145)) +- Update assets events ([#158](https://github.com/MetaMask/snap-solana-wallet/pull/158)) +- Switch order of fields on send ([#154](https://github.com/MetaMask/snap-solana-wallet/pull/154)) +- Sip29 in rpc ([#153](https://github.com/MetaMask/snap-solana-wallet/pull/153)) + +### Fixed + +- Clicking on max when balance is zero ([#155](https://github.com/MetaMask/snap-solana-wallet/pull/155)) +- Transactions list request had improper validation ([#156](https://github.com/MetaMask/snap-solana-wallet/pull/156)) + +## [1.2.0] + +### Added + +- Format tokens to the decimal ([#150](https://github.com/MetaMask/snap-solana-wallet/pull/150)) +- Add coverage for the transactions cronjob ([#149](https://github.com/MetaMask/snap-solana-wallet/pull/149)) +- Add tests for the transactions history feature ([#143](https://github.com/MetaMask/snap-solana-wallet/pull/143)) +- Send SPL tokens on send flow ([#144](https://github.com/MetaMask/snap-solana-wallet/pull/144)) +- Adds onUpdate hook ([#131](https://github.com/MetaMask/snap-solana-wallet/pull/131)) +- Snap send SPL tokens transaction ([#130](https://github.com/MetaMask/snap-solana-wallet/pull/130)) +- Add SPL token transaction parsing ([#124](https://github.com/MetaMask/snap-solana-wallet/pull/124)) + +### Fixed + +- Validating all keyring I/O ([#151](https://github.com/MetaMask/snap-solana-wallet/pull/151)) +- Can progress on send when amount is empty ([#148](https://github.com/MetaMask/snap-solana-wallet/pull/148)) +- Chunk token metadata requests ([#147](https://github.com/MetaMask/snap-solana-wallet/pull/147)) +- Allow token metadata service to fetch from tokens from different networks at the same time ([#146](https://github.com/MetaMask/snap-solana-wallet/pull/146)) +- Expose buildTransactionMessage + base64 encoder / decoder ([#142](https://github.com/MetaMask/snap-solana-wallet/pull/142)) + +## [1.1.0] + +### Added + +- Add `scopes` field to `KeyringAccount` ([#134](https://github.com/MetaMask/snap-solana-wallet/pull/134)) +- Add support for spanish translations ([#127](https://github.com/MetaMask/snap-solana-wallet/pull/127)) +- New Crowdin translations by Github Action ([#121](https://github.com/MetaMask/snap-solana-wallet/pull/121)) +- List account assets ([#125](https://github.com/MetaMask/snap-solana-wallet/pull/125)) +- Get transaction fee ([#123](https://github.com/MetaMask/snap-solana-wallet/pull/123)) + +### Changed + +- Check client status for running cronjobs ([#128](https://github.com/MetaMask/snap-solana-wallet/pull/128)) + +## [1.0.4] + +### Added + +- feat: add `keyring_listAccountTransactions` support to the snap ([#101](https://github.com/MetaMask/snap-solana-wallet/pull/101)) +- New Crowdin translations by Github Action ([#96](https://github.com/MetaMask/snap-solana-wallet/pull/96)) +- feat: localised currency ([#118](https://github.com/MetaMask/snap-solana-wallet/pull/118)) +- feat: add retry logic globally to all RPC calls ([#113](https://github.com/MetaMask/snap-solana-wallet/pull/113)) +- feat: failover rpc ([#115](https://github.com/MetaMask/snap-solana-wallet/pull/115)) +- feat: snap show loader for pending txs ([#117](https://github.com/MetaMask/snap-solana-wallet/pull/117)) + +### Changed + +- fix: remove all logging from prod ([#120](https://github.com/MetaMask/snap-solana-wallet/pull/120)) +- fix: remove logging ([#119](https://github.com/MetaMask/snap-solana-wallet/pull/119)) +- fix: balance validation not triggering + send tests ([#116](https://github.com/MetaMask/snap-solana-wallet/pull/116)) +- fix: to address links to from in explorer ([#114](https://github.com/MetaMask/snap-solana-wallet/pull/114)) +- fix: scope passed to send form ([#112](https://github.com/MetaMask/snap-solana-wallet/pull/112)) +- fix: make building the send context robust to errors ([#111](https://github.com/MetaMask/snap-solana-wallet/pull/111)) + +## [1.0.3] + +### Added + +- feat: add a component that wraps the send flow ([#106](https://github.com/MetaMask/snap-solana-wallet/pull/106)) +- feat: snap get live token rates ([#102](https://github.com/MetaMask/snap-solana-wallet/pull/102)) +- feat: localize ui ([#99](https://github.com/MetaMask/snap-solana-wallet/pull/99)) +- feat: Adds the Tx result view after confirming it ([#92](https://github.com/MetaMask/snap-solana-wallet/pull/92)) + +### Changed + +- fix: the tx confirmation result view now uses price from the tx time ([#107](https://github.com/MetaMask/snap-solana-wallet/pull/107)) +- fix: compile error ([#104](https://github.com/MetaMask/snap-solana-wallet/pull/104)) +- fix: show address name in confirmation ([#105](https://github.com/MetaMask/snap-solana-wallet/pull/105)) +- fix: confirmation result ([#103](https://github.com/MetaMask/snap-solana-wallet/pull/103)) +- chore: clean up dependencies object ([#98](https://github.com/MetaMask/snap-solana-wallet/pull/98)) +- chore: updates translations ([#100](https://github.com/MetaMask/snap-solana-wallet/pull/100)) + +## [1.0.2] + +### Added + +- feat: connect 'Initiate Transfer' and 'Confirmation' dialogs together ([#84](https://github.com/MetaMask/snap-solana-wallet/pull/84)) +- feat: setup translations ([#86](https://github.com/MetaMask/snap-solana-wallet/pull/86)) + +## [1.0.1] + +### Added + +- docs: document the .env ([#87](https://github.com/MetaMask/snap-solana-wallet/pull/87)) +- [SOL-58] snap mock rpc calls for unit tests ([#85](https://github.com/MetaMask/snap-solana-wallet/pull/85)) +- feat: amount input ([#78](https://github.com/MetaMask/snap-solana-wallet/pull/78)) +- feat: implement TransactionConfirmation dialog ([#80](https://github.com/MetaMask/snap-solana-wallet/pull/80)) +- [SOL-43] adds the To input field ([#76](https://github.com/MetaMask/snap-solana-wallet/pull/76)) +- [SOL-45] feat: implement Solana transactions ([#70](https://github.com/MetaMask/snap-solana-wallet/pull/70)) +- feat: account selector ([#73](https://github.com/MetaMask/snap-solana-wallet/pull/73)) +- feat: handle send action ([#72](https://github.com/MetaMask/snap-solana-wallet/pull/72)) + +### Fixed + +- fix: sonar on main ([#82](https://github.com/MetaMask/snap-solana-wallet/pull/82)) +- fix: add tests for all utils that didn't have them ([#83](https://github.com/MetaMask/snap-solana-wallet/pull/83)) +- fix: cors erros using Grove for mainnet rpc provider ([#77](https://github.com/MetaMask/snap-solana-wallet/pull/77)) +- fix: support get account balances on different chains ([#71](https://github.com/MetaMask/snap-solana-wallet/pull/71)) + +## [1.0.0] + +### Added + +- Get account balances ([#67](https://github.com/MetaMask/snap-solana-wallet/pull/67)) +- Delete account keyring method ([#64](https://github.com/MetaMask/snap-solana-wallet/pull/64)) +- Create + List Solana accounts ([#54](https://github.com/MetaMask/snap-solana-wallet/pull/54)) + +### Changed + +- Create and remove index mismatch ([#66](https://github.com/MetaMask/snap-solana-wallet/pull/66)) +- Adds the keyring-api package with solana support ([#63](https://github.com/MetaMask/snap-solana-wallet/pull/63)) + +## [0.1.1] + +### Added + +- Keyring and rpc listeners +- CI configs +- Build config +- Sonarcloud to github workflow ([#25](https://github.com/MetaMask/snap-solana-wallet/pull/25)) +- Snap setup + +[Unreleased]: https://github.com/MetaMask/snap-solana-wallet/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.10.0...v3.0.0 +[2.10.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.9.1...v2.10.0 +[2.9.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.9.0...v2.9.1 +[2.9.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.8.0...v2.9.0 +[2.8.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.7.4...v2.8.0 +[2.7.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.7.3...v2.7.4 +[2.7.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.7.2...v2.7.3 +[2.7.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.7.1...v2.7.2 +[2.7.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.7.0...v2.7.1 +[2.7.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.6.2...v2.7.0 +[2.6.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.6.1...v2.6.2 +[2.6.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.6.0...v2.6.1 +[2.6.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.5.1...v2.6.0 +[2.5.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.5.0...v2.5.1 +[2.5.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.8...v2.5.0 +[2.4.8]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.7...v2.4.8 +[2.4.7]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.6...v2.4.7 +[2.4.6]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.5...v2.4.6 +[2.4.5]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.4...v2.4.5 +[2.4.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.3...v2.4.4 +[2.4.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.2...v2.4.3 +[2.4.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.1...v2.4.2 +[2.4.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.4.0...v2.4.1 +[2.4.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.11...v2.4.0 +[2.3.11]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.10...v2.3.11 +[2.3.10]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.9...v2.3.10 +[2.3.9]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.8...v2.3.9 +[2.3.8]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.7...v2.3.8 +[2.3.7]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.6...v2.3.7 +[2.3.6]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.5...v2.3.6 +[2.3.5]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.4...v2.3.5 +[2.3.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.3...v2.3.4 +[2.3.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.2...v2.3.3 +[2.3.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.1...v2.3.2 +[2.3.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.3.0...v2.3.1 +[2.3.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.2.0...v2.3.0 +[2.2.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.1.4...v2.2.0 +[2.1.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.1.3...v2.1.4 +[2.1.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.1.2...v2.1.3 +[2.1.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.1.1...v2.1.2 +[2.1.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.1.0...v2.1.1 +[2.1.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v2.0.0...v2.1.0 +[2.0.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.36.0...v2.0.0 +[1.36.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.35.2...v1.36.0 +[1.35.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.35.1...v1.35.2 +[1.35.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.35.0...v1.35.1 +[1.35.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.34.0...v1.35.0 +[1.34.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.33.4...v1.34.0 +[1.33.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.33.3...v1.33.4 +[1.33.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.33.2...v1.33.3 +[1.33.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.33.1...v1.33.2 +[1.33.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.33.0...v1.33.1 +[1.33.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.32.0...v1.33.0 +[1.32.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.31.2...v1.32.0 +[1.31.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.31.1...v1.31.2 +[1.31.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.31.0...v1.31.1 +[1.31.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.30.4...v1.31.0 +[1.30.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.30.3...v1.30.4 +[1.30.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.30.2...v1.30.3 +[1.30.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.30.1...v1.30.2 +[1.30.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.30.0...v1.30.1 +[1.30.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.29.0...v1.30.0 +[1.29.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.28.3...v1.29.0 +[1.28.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.28.2...v1.28.3 +[1.28.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.28.1...v1.28.2 +[1.28.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.28.0...v1.28.1 +[1.28.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.27.0...v1.28.0 +[1.27.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.26.1...v1.27.0 +[1.26.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.26.0...v1.26.1 +[1.26.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.25.1...v1.26.0 +[1.25.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.25.0...v1.25.1 +[1.25.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.24.0...v1.25.0 +[1.24.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.23.0...v1.24.0 +[1.23.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.22.0...v1.23.0 +[1.22.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.21.0...v1.22.0 +[1.21.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.20.0...v1.21.0 +[1.20.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.19.0...v1.20.0 +[1.19.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.18.1...v1.19.0 +[1.18.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.18.0...v1.18.1 +[1.18.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.17.0...v1.18.0 +[1.17.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.16.1...v1.17.0 +[1.16.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.16.0...v1.16.1 +[1.16.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.15.1...v1.16.0 +[1.15.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.15.0...v1.15.1 +[1.15.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.14.0...v1.15.0 +[1.14.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.13.0...v1.14.0 +[1.13.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.12.0...v1.13.0 +[1.12.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.11.0...v1.12.0 +[1.11.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.10.1...v1.11.0 +[1.10.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.10.0...v1.10.1 +[1.10.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.9.0...v1.10.0 +[1.9.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.8.0...v1.9.0 +[1.8.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.7.0...v1.8.0 +[1.7.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.6.0...v1.7.0 +[1.6.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.5.0...v1.6.0 +[1.5.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.4.0...v1.5.0 +[1.4.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.0.4...v1.1.0 +[1.0.4]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/MetaMask/snap-solana-wallet/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/MetaMask/snap-solana-wallet/compare/v0.1.1...v1.0.0 +[0.1.1]: https://github.com/MetaMask/snap-solana-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/solana-wallet-snap/LICENSE b/merged-packages/solana-wallet-snap/LICENSE new file mode 100644 index 00000000..8bc6b28b --- /dev/null +++ b/merged-packages/solana-wallet-snap/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 MetaMask + +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, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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/solana-wallet-snap/README.md b/merged-packages/solana-wallet-snap/README.md new file mode 100644 index 00000000..45492502 --- /dev/null +++ b/merged-packages/solana-wallet-snap/README.md @@ -0,0 +1,55 @@ +# Solana Wallet Snap + +This package contains the source code for the Solana Wallet Snap - a MetaMask Snap that enables Solana blockchain functionality directly within your MetaMask wallet. The Snap allows users to: + +- Create and manage Solana accounts +- View SOL and SPL token balances +- Sign messages and transactions +- Send and receive transactions +- Connect to Solana dApps + +The Snap is built using the MetaMask Snaps SDK and integrates with Solana's web3.js library for blockchain interactions. It follows best practices for security and provides a seamless user experience within the familiar MetaMask interface. + +![Snap UI](./docs/snap-ui.png) + +## Running the snap locally + +```bash +cp packages/snap/.env.example packages/snap/.env +# Set content from https://my.1password.com/app#/gebbq4jvzj7iexnbirelfitv2y/AllItems/gebbq4jvzj7iexnbirelfitv2yvis64f7yhxuoi277r3hagj7ndi + +yarn workspace @metamask/solana-wallet-snap start +``` + +> [!WARNING] +> When snap updates you will need to still reconnect from the dapp to see changes + +> [!TIP] +> Alternatively you can build and serve the snap manually. This can sometimes be more stable than watch mode but requires a manual rebuild and serve anytime there is a change on the snap. + +## Changing env vars + +> [!WARNING] +> The `.env` values are bundled into the built snap and affect its [shasum](./snap.manifest.json#L10). To avoid build failures in CI, make sure your local `.env` file matches exactly with the CI repository secrets. Any mismatch will cause the CI to reject the build due to different checksums. + +If you need to change the environment variables, follow this checklist: + +1. Update the `packages/snap/.env` file with the new keys and/or values +2. Update the `packages/snap/.env.example` file with the new keys +3. Update the [1Password](https://my.1password.com/app#/gebbq4jvzj7iexnbirelfitv2y/AllItems/gebbq4jvzj7iexnbirelfitv2yvis64f7yhxuoi277r3hagj7ndi) entry with the new values +4. Update the [GitHub repository secrets](https://github.com/MetaMask/solana-wallet-snap/settings/secrets) with the new values +5. Run `yarn start` to regenerate the build and its shasum + +## Building and serving snap manually + +```bash +yarn workspace @metamask/solana-wallet-snap build +yarn workspace @metamask/solana-wallet-snap serve +``` + +Further reading: + +- [Development](../../docs/development.md) +- [Contributing](../../docs/contributing.md) +- [Testing](../../docs/testing.md) +- [Releasing](../../docs/release.md) diff --git a/merged-packages/solana-wallet-snap/docs/snap-ui.png b/merged-packages/solana-wallet-snap/docs/snap-ui.png new file mode 100644 index 00000000..22557235 Binary files /dev/null and b/merged-packages/solana-wallet-snap/docs/snap-ui.png differ diff --git a/merged-packages/solana-wallet-snap/images/check.svg b/merged-packages/solana-wallet-snap/images/check.svg new file mode 100644 index 00000000..0b4735d3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/images/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/merged-packages/solana-wallet-snap/images/coin.svg b/merged-packages/solana-wallet-snap/images/coin.svg new file mode 100644 index 00000000..afe0e624 --- /dev/null +++ b/merged-packages/solana-wallet-snap/images/coin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/merged-packages/solana-wallet-snap/images/error.svg b/merged-packages/solana-wallet-snap/images/error.svg new file mode 100644 index 00000000..6a6d2329 --- /dev/null +++ b/merged-packages/solana-wallet-snap/images/error.svg @@ -0,0 +1,3 @@ + + + diff --git a/merged-packages/solana-wallet-snap/images/icon.svg b/merged-packages/solana-wallet-snap/images/icon.svg new file mode 100644 index 00000000..7eae6731 --- /dev/null +++ b/merged-packages/solana-wallet-snap/images/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/merged-packages/solana-wallet-snap/jest.config.js b/merged-packages/solana-wallet-snap/jest.config.js new file mode 100644 index 00000000..27145c5e --- /dev/null +++ b/merged-packages/solana-wallet-snap/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + preset: '@metamask/snaps-jest', + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + '^.+\\.svg$': '/svg-transformer.js', + }, + verbose: true, + collectCoverage: true, + setupFilesAfterEnv: ['./jest.setup.ts'], + coverageReporters: ['html', 'json-summary', 'text', 'lcov'], +}; diff --git a/merged-packages/solana-wallet-snap/jest.setup.ts b/merged-packages/solana-wallet-snap/jest.setup.ts new file mode 100644 index 00000000..df1465e9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/jest.setup.ts @@ -0,0 +1,24 @@ +import { jest } from '@jest/globals'; +import BigNumber from 'bignumber.js'; +import dotenv from 'dotenv'; + +import logger from './src/core/utils/logger'; + +dotenv.config(); + +// Lowest precision we ever go for: MicroLamports represented in Sol amount +BigNumber.config({ EXPONENTIAL_AT: 16 }); + +// Mock the console methods +jest.spyOn(logger, 'log').mockImplementation(() => { + /* no-op */ +}); +jest.spyOn(logger, 'info').mockImplementation(() => { + /* no-op */ +}); +jest.spyOn(logger, 'warn').mockImplementation(() => { + /* no-op */ +}); +jest.spyOn(logger, 'error').mockImplementation(() => { + /* no-op */ +}); diff --git a/merged-packages/solana-wallet-snap/locales/de.json b/merged-packages/solana-wallet-snap/locales/de.json new file mode 100644 index 00000000..e022a54d --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/de.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "Senden" + }, + "send.balance": { + "message": "Guthaben" + }, + "send.maxButton": { + "message": "Max." + }, + "send.cancelButton": { + "message": "Stornieren" + }, + "send.continueButton": { + "message": "Fortfahren" + }, + "send.fromField": { + "message": "Von" + }, + "send.toField": { + "message": "An" + }, + "send.amountField": { + "message": "Betrag" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Öffentliche Adresse eingeben" + }, + "send.fromRequiredError": { + "message": "Konto erforderlich" + }, + "send.amountRequiredError": { + "message": "Betrag erforderlich" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Betrag muss größer sein als {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Unzureichendes SOL-Guthaben zur Begleichung der Transaktionsgebühr" + }, + "send.toRequiredError": { + "message": "Adresse des Empfängers ist erforderlich" + }, + "send.toInvalidError": { + "message": "Ungültige Solana-Adresse" + }, + "send.insufficientBalance": { + "message": "Unzureichendes Guthaben" + }, + "send.simulationTitleAPIError": { + "message": "Aufgrund eines Fehlers konnten wir nicht auf Sicherheitsbenachrichtigungen prüfen." + }, + "send.simulationMessageAPIError": { + "message": "Fahren Sie nur fort, wenn Sie jeder beteiligten Adresse vertrauen." + }, + "send.simulationTitleError": { + "message": "Transaktionssimulation fehlgeschlagen" + }, + "send.simulationMessageError": { + "message": "Diese Transaktion wurde während der Simulation zurückgesetzt." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Preisangaben für Token in {currency} sind derzeit nicht verfügbar. Sie können dennoch Token versenden." + }, + "send.confirmation.title": { + "message": "Überprüfen" + }, + "send.confirmation.from": { + "message": "Von" + }, + "send.confirmation.recipient": { + "message": "Empfänger" + }, + "send.confirmation.network": { + "message": "Netzwerk" + }, + "send.confirmation.transactionSpeed": { + "message": "Transaktionsgeschwindigkeit" + }, + "send.confirmation.fee": { + "message": "Netzwerk-Gebühr" + }, + "send.confirmation.sendButton": { + "message": "Senden" + }, + "send.confirmation.cancelButton": { + "message": "Stornieren" + }, + "send.confirmation.viewTransaction": { + "message": "Transaktion einsehen" + }, + "send.send-pending.title": { + "message": "Wird gesendet ..." + }, + "send.send-pending.subtitle": { + "message": "Übermittlung Ihrer Transaktion." + }, + "send.transaction-success.title": { + "message": "Versendet" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} wurde erfolgreich gesendet" + }, + "send.transaction-failure.title": { + "message": "Transaktion fehlgeschlagen" + }, + "send.transaction-failure.subtitle": { + "message": "{amount} {tokenSymbol} konnte nicht gesendet werden" + }, + "confirmation.account": { + "message": "Konto" + }, + "confirmation.network": { + "message": "Netzwerk" + }, + "confirmation.estimatedChanges": { + "message": "Geschätzte Änderungen" + }, + "confirmation.estimatedChanges.send": { + "message": "Sie senden" + }, + "confirmation.estimatedChanges.receive": { + "message": "Sie empfangen" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Nicht verfügbar" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Keine Änderungen" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Die geschätzten Änderungen beziehen sich auf das, was passieren könnte, wenn Sie diese Transaktion durchführen. Es handelt sich hierbei lediglich um eine Prognose, nicht um eine Garantie." + }, + "confirmation.advanced.show": { + "message": "Erweiterte Einstellungen anzeigen" + }, + "confirmation.advanced.hide": { + "message": "Erweiterte Einstellungen verbergen" + }, + "confirmation.cancelButton": { + "message": "Stornieren" + }, + "confirmation.confirmButton": { + "message": "Bestätigen" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaktionsanfrage" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Unbekannt" + }, + "confirmation.advanced.programId": { + "message": "Programm-ID" + }, + "confirmation.advanced.data": { + "message": "Daten" + }, + "confirmation.fee": { + "message": "Netzwerk-Gebühr" + }, + "confirmation.feeError": { + "message": "Gebühr kann nicht geschätzt werden" + }, + "confirmation.origin": { + "message": "Anfrage von" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Diese Transaktion wurde während der Simulation zurückgesetzt." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Grund: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Dies ist eine betrügerische Anfrage" + }, + "confirmation.validationErrorSubtitle": { + "message": "Wenn Sie diese Anfrage genehmigen, wird eine für Betrügereien bekannte Drittpartei Ihre gesamten Assets an sich reißen." + }, + "confirmation.validationErrorLearnMore": { + "message": "Mehr erfahren" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Sicherheitshinweis von" + }, + "confirmation.signMessage.title": { + "message": "Nachricht unterzeichnen" + }, + "confirmation.signMessage.message": { + "message": "Nachricht" + }, + "confirmation.signIn.title": { + "message": "Anmeldeanfrage" + }, + "confirmation.signIn.description": { + "message": "Eine Website verlangt eine Anmeldung als Nachweis, dass Sie der Kontoinhaber sind." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Unbekannte Domain" + }, + "confirmation.signIn.signingInWith": { + "message": "Anmelden mit" + }, + "confirmation.signIn.badAccount": { + "message": "Diese Seite fordert Sie auf, sich mit dem falschen Konto anzumelden." + }, + "confirmation.signIn.message": { + "message": "Nachricht" + }, + "confirmation.signIn.statement": { + "message": "Erklärung" + }, + "confirmation.signIn.version": { + "message": "Version" + }, + "confirmation.signIn.chainId": { + "message": "Chain-ID" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Ausgestellt am" + }, + "confirmation.signIn.expirationTime": { + "message": "Ablaufzeit" + }, + "confirmation.signIn.notBefore": { + "message": "Nicht vor" + }, + "confirmation.signIn.requestId": { + "message": "Request-ID" + }, + "confirmation.signIn.resources": { + "message": "Ressourcen" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "An account with the same address already exists." + }, + "transactionScan.errors.insufficientSol": { + "message": "Account does not have enough SOL to perform the operation." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Die Transaktion wurde aufgrund einer Überschreitung der Slippage-Toleranz zurückgesetzt." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/el.json b/merged-packages/solana-wallet-snap/locales/el.json new file mode 100644 index 00000000..e782ef25 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/el.json @@ -0,0 +1,269 @@ +{ + "locale": "el", + "messages": { + "send.title": { + "message": "Αποστολή" + }, + "send.balance": { + "message": "Υπόλοιπο" + }, + "send.maxButton": { + "message": "Μεγ" + }, + "send.cancelButton": { + "message": "Άκυρο" + }, + "send.continueButton": { + "message": "Συνέχεια" + }, + "send.fromField": { + "message": "Από" + }, + "send.toField": { + "message": "Προς" + }, + "send.amountField": { + "message": "Ποσό" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Εισάγετε τη δημόσια διεύθυνση" + }, + "send.fromRequiredError": { + "message": "Απαιτείται ο λογαριασμός" + }, + "send.amountRequiredError": { + "message": "Απαιτείται το ποσό" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Το ποσό πρέπει να είναι μεγαλύτερο από {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Ανεπαρκές υπόλοιπο στη Solana (SOL) για την κάλυψη των τελών συναλλαγής" + }, + "send.toRequiredError": { + "message": "Απαιτείται η διεύθυνση" + }, + "send.toInvalidError": { + "message": "Μη έγκυρη διεύθυνση Solana" + }, + "send.insufficientBalance": { + "message": "Ανεπαρκές υπόλοιπο" + }, + "send.simulationTitleAPIError": { + "message": "Λόγω κάποιου σφάλματος, δεν μπορέσαμε να ελέγξουμε για ειδοποιήσεις ασφαλείας." + }, + "send.simulationMessageAPIError": { + "message": "Συνεχίστε μόνο αν εμπιστεύεστε κάθε διεύθυνση που εμπλέκεται." + }, + "send.simulationTitleError": { + "message": "Η προσομοίωση της συναλλαγής απέτυχε" + }, + "send.simulationMessageError": { + "message": "Η συναλλαγή αυτή ακυρώθηκε κατά τη διάρκεια της προσομοίωσης." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Οι τιμές των tokens σε {currency} δεν είναι προς το παρόν διαθέσιμες. Μπορείτε ακόμα να στείλετε tokens." + }, + "send.confirmation.title": { + "message": "Επανέλεγχος" + }, + "send.confirmation.from": { + "message": "Από" + }, + "send.confirmation.recipient": { + "message": "Παραλήπτης" + }, + "send.confirmation.network": { + "message": "Δίκτυο" + }, + "send.confirmation.transactionSpeed": { + "message": "Ταχύτητα συναλλαγών" + }, + "send.confirmation.fee": { + "message": "Τέλη δικτύου" + }, + "send.confirmation.sendButton": { + "message": "Αποστολή" + }, + "send.confirmation.cancelButton": { + "message": "Άκυρο" + }, + "send.confirmation.viewTransaction": { + "message": "Προβολή συναλλαγών" + }, + "send.send-pending.title": { + "message": "Αποστολή..." + }, + "send.send-pending.subtitle": { + "message": "Η συναλλαγή σας υποβλήθηκε." + }, + "send.transaction-success.title": { + "message": "Εστάλη" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} εστάλη με επιτυχία" + }, + "send.transaction-failure.title": { + "message": "Η συναλλαγή απέτυχε" + }, + "send.transaction-failure.subtitle": { + "message": "Δεν είναι δυνατή η αποστολή {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Λογαριασμός" + }, + "confirmation.network": { + "message": "Δίκτυο" + }, + "confirmation.estimatedChanges": { + "message": "Εκτιμώμενες αλλαγές" + }, + "confirmation.estimatedChanges.send": { + "message": "Θα στείλετε" + }, + "confirmation.estimatedChanges.receive": { + "message": "Θα λάβετε" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Μη διαθέσιμο" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Δεν υπάρχουν αλλαγές" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Οι εκτιμώμενες αλλαγές είναι αυτές που μπορεί να συμβούν αν προχωρήσετε σε αυτή τη συναλλαγή. Πρόκειται απλώς για μια πρόβλεψη, δεν αποτελεί εγγύηση." + }, + "confirmation.advanced.show": { + "message": "Εμφάνιση προηγμένων" + }, + "confirmation.advanced.hide": { + "message": "Απόκρυψη προηγμένων" + }, + "confirmation.cancelButton": { + "message": "Άκυρο" + }, + "confirmation.confirmButton": { + "message": "Επιβεβαίωση" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Αίτημα συναλλαγής" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Άγνωστο" + }, + "confirmation.advanced.programId": { + "message": "Αναγνωριστικό προγράμματος" + }, + "confirmation.advanced.data": { + "message": "Δεδομένα" + }, + "confirmation.fee": { + "message": "Τέλη δικτύου" + }, + "confirmation.feeError": { + "message": "Δεν είναι δυνατή η εκτίμηση των τελών" + }, + "confirmation.origin": { + "message": "Ζητήθηκε από" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Η συναλλαγή αυτή ακυρώθηκε κατά τη διάρκεια της προσομοίωσης." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Αιτία: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Πρόκειται για παραπλανητικό αίτημα" + }, + "confirmation.validationErrorSubtitle": { + "message": "Εάν εγκρίνετε αυτό το αίτημα, ένας τρίτος που είναι γνωστός για απάτες θα πάρει όλα τα περιουσιακά σας στοιχεία." + }, + "confirmation.validationErrorLearnMore": { + "message": "Μάθετε περισσότερα" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Συμβουλές ασφαλείας από" + }, + "confirmation.signMessage.title": { + "message": "Υπογραφή μηνύματος" + }, + "confirmation.signMessage.message": { + "message": "Μήνυμα" + }, + "confirmation.signIn.title": { + "message": "Αίτημα σύνδεσης" + }, + "confirmation.signIn.description": { + "message": "Ένας ιστότοπος θέλει να συνδεθείτε για να αποδείξετε ότι είστε ο κάτοχος αυτού του λογαριασμού." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Άγνωστος τομέας" + }, + "confirmation.signIn.signingInWith": { + "message": "Σύνδεση με" + }, + "confirmation.signIn.badAccount": { + "message": "Αυτός ο ιστότοπος σας ζητάει να συνδεθείτε χρησιμοποιώντας λάθος λογαριασμό." + }, + "confirmation.signIn.message": { + "message": "Μήνυμα" + }, + "confirmation.signIn.statement": { + "message": "Δήλωση" + }, + "confirmation.signIn.version": { + "message": "Έκδοση" + }, + "confirmation.signIn.chainId": { + "message": "Αναγνωριστικό αλυσίδας" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Εκδόθηκε στις" + }, + "confirmation.signIn.expirationTime": { + "message": "Χρόνος λήξης" + }, + "confirmation.signIn.notBefore": { + "message": "Όχι πριν από" + }, + "confirmation.signIn.requestId": { + "message": "Αναγνωριστικό αιτήματος" + }, + "confirmation.signIn.resources": { + "message": "Πόροι" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Υπάρχει ήδη ένας λογαριασμός με την ίδια διεύθυνση." + }, + "transactionScan.errors.insufficientSol": { + "message": "Ο λογαριασμός δεν διαθέτει αρκετά SOL για να εκτελέσει την ενέργεια." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Η συναλλαγή ακυρώθηκε επειδή ξεπεράστηκε το όριο αποδεκτής απόκλισης." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/en.json b/merged-packages/solana-wallet-snap/locales/en.json new file mode 100644 index 00000000..9e4e4e7e --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/en.json @@ -0,0 +1,287 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "Send" + }, + "send.balance": { + "message": "Balance" + }, + "send.maxButton": { + "message": "Max" + }, + "send.cancelButton": { + "message": "Cancel" + }, + "send.continueButton": { + "message": "Continue" + }, + "send.fromField": { + "message": "From" + }, + "send.toField": { + "message": "To" + }, + "send.amountField": { + "message": "Amount" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Enter public address or domain name" + }, + "send.fromRequiredError": { + "message": "Account is required" + }, + "send.amountRequiredError": { + "message": "Amount is required" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Amount must be greater than {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Insufficient SOL balance to cover the transaction fee" + }, + "send.toRequiredError": { + "message": "To address is required" + }, + "send.toInvalidError": { + "message": "Invalid Solana address or domain name" + }, + "send.toInvalidErrorDomain": { + "message": "Unable to resolve domain name" + }, + "send.toDomainResolutionStatus.initial": { + "message": "Domain" + }, + "send.toDomainResolutionStatus.fetching": { + "message": "Resolving domain name..." + }, + "send.toDomainResolutionStatus.fetched": { + "message": "Domain name resolved." + }, + "send.toDomainResolutionStatus.error": { + "message": "Unable to resolve domain name." + }, + "send.insufficientBalance": { + "message": "Insufficient balance" + }, + "send.simulationTitleAPIError": { + "message": "Because of an error, we couldn't check for security alerts." + }, + "send.simulationMessageAPIError": { + "message": "Only continue if you trust every address involved." + }, + "send.simulationTitleError": { + "message": "Transaction simulation failed" + }, + "send.simulationMessageError": { + "message": "This transaction was reverted during simulation." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Prices for tokens in {currency} are currently not available. You are still able to send tokens." + }, + "send.confirmation.title": { + "message": "Review" + }, + "send.confirmation.from": { + "message": "From" + }, + "send.confirmation.recipient": { + "message": "Recipient" + }, + "send.confirmation.network": { + "message": "Network" + }, + "send.confirmation.transactionSpeed": { + "message": "Transaction speed" + }, + "send.confirmation.fee": { + "message": "Network fee" + }, + "send.confirmation.sendButton": { + "message": "Send" + }, + "send.confirmation.cancelButton": { + "message": "Cancel" + }, + "send.confirmation.viewTransaction": { + "message": "View transaction" + }, + "send.send-pending.title": { + "message": "Sending..." + }, + "send.send-pending.subtitle": { + "message": "Your transaction was submitted." + }, + "send.transaction-success.title": { + "message": "Sent" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} was successfully sent" + }, + "send.transaction-failure.title": { + "message": "Transaction failed" + }, + "send.transaction-failure.subtitle": { + "message": "Unable to send {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.recipient": { + "message": "Recipient" + }, + "confirmation.network": { + "message": "Network" + }, + "confirmation.estimatedChanges": { + "message": "Estimated changes" + }, + "confirmation.estimatedChanges.send": { + "message": "You send" + }, + "confirmation.estimatedChanges.receive": { + "message": "You receive" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Not available" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "No 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.advanced.show": { + "message": "Show advanced" + }, + "confirmation.advanced.hide": { + "message": "Hide advanced" + }, + "confirmation.cancelButton": { + "message": "Cancel" + }, + "confirmation.confirmButton": { + "message": "Confirm" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaction request" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Unknown" + }, + "confirmation.advanced.programId": { + "message": "Program ID" + }, + "confirmation.advanced.data": { + "message": "Data" + }, + "confirmation.fee": { + "message": "Network fee" + }, + "confirmation.feeError": { + "message": "Unable to estimate fee" + }, + "confirmation.origin": { + "message": "Request from" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "This transaction was reverted during simulation." + }, + "confirmation.simulationErrorSubtitle": { + "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.validationErrorLearnMore": { + "message": "Learn more" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Security advice by" + }, + "confirmation.signMessage.title": { + "message": "Sign message" + }, + "confirmation.signMessage.message": { + "message": "Message" + }, + "confirmation.signIn.title": { + "message": "Sign-in request" + }, + "confirmation.signIn.description": { + "message": "A site wants you to sign in to prove you own this account." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Unknown domain" + }, + "confirmation.signIn.signingInWith": { + "message": "Signing in with" + }, + "confirmation.signIn.badAccount": { + "message": "This site is asking you to sign in using the wrong account." + }, + "confirmation.signIn.message": { + "message": "Message" + }, + "confirmation.signIn.statement": { + "message": "Statement" + }, + "confirmation.signIn.version": { + "message": "Version" + }, + "confirmation.signIn.chainId": { + "message": "Chain ID" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Issued at" + }, + "confirmation.signIn.expirationTime": { + "message": "Expiration time" + }, + "confirmation.signIn.notBefore": { + "message": "Not before" + }, + "confirmation.signIn.requestId": { + "message": "Request ID" + }, + "confirmation.signIn.resources": { + "message": "Resources" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "An account with the same address already exists." + }, + "transactionScan.errors.insufficientSol": { + "message": "Account does not have enough SOL to perform the operation." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "The transaction was reverted because the slippage tolerance was exceeded." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/es.json b/merged-packages/solana-wallet-snap/locales/es.json new file mode 100644 index 00000000..b7c89e8b --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/es.json @@ -0,0 +1,269 @@ +{ + "locale": "es", + "messages": { + "send.title": { + "message": "Enviar" + }, + "send.balance": { + "message": "Saldo" + }, + "send.maxButton": { + "message": "Máx." + }, + "send.cancelButton": { + "message": "Cancelar" + }, + "send.continueButton": { + "message": "Continuar" + }, + "send.fromField": { + "message": "De" + }, + "send.toField": { + "message": "Para" + }, + "send.amountField": { + "message": "Monto" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Introduzca la dirección pública" + }, + "send.fromRequiredError": { + "message": "La cuenta es obligatoria" + }, + "send.amountRequiredError": { + "message": "El monto es obligatorio" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "El valor debe ser superior a {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Saldo de SOL insuficiente para cubrir la tarifa de transacción" + }, + "send.toRequiredError": { + "message": "La dirección del destinatario es obligatoria" + }, + "send.toInvalidError": { + "message": "Dirección de Solana no válida" + }, + "send.insufficientBalance": { + "message": "Saldo insuficiente" + }, + "send.simulationTitleAPIError": { + "message": "Debido a un error, no pudimos verificar las alertas de seguridad." + }, + "send.simulationMessageAPIError": { + "message": "Continúa solo si confías en todas las direcciones involucradas." + }, + "send.simulationTitleError": { + "message": "Simulación de transacción fallida" + }, + "send.simulationMessageError": { + "message": "Esta transacción se revirtió durante la simulación." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Los precios de los tokens en {currency} no están disponibles actualmente. Aún puede enviar tokens." + }, + "send.confirmation.title": { + "message": "Revisar" + }, + "send.confirmation.from": { + "message": "De" + }, + "send.confirmation.recipient": { + "message": "Destinatario" + }, + "send.confirmation.network": { + "message": "Red" + }, + "send.confirmation.transactionSpeed": { + "message": "Velocidad de la transacción" + }, + "send.confirmation.fee": { + "message": "Tarifa de red" + }, + "send.confirmation.sendButton": { + "message": "Enviar" + }, + "send.confirmation.cancelButton": { + "message": "Cancelar" + }, + "send.confirmation.viewTransaction": { + "message": "Ver transacción" + }, + "send.send-pending.title": { + "message": "Enviando..." + }, + "send.send-pending.subtitle": { + "message": "Se envió su transacción." + }, + "send.transaction-success.title": { + "message": "Enviado" + }, + "send.transaction-success.subtitle": { + "message": "El envío de {amount} {tokenSymbol} se realizó correctamente" + }, + "send.transaction-failure.title": { + "message": "Transacción fallida" + }, + "send.transaction-failure.subtitle": { + "message": "Error al enviar {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.network": { + "message": "Red" + }, + "confirmation.estimatedChanges": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.estimatedChanges.receive": { + "message": "Usted recibe" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "No disponible" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Sin cambios" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Los cambios estimados son los que podrían producirse si sigue adelante con esta transacción. Esto es solo una predicción, no una garantía." + }, + "confirmation.advanced.show": { + "message": "Mostrar avanzadas" + }, + "confirmation.advanced.hide": { + "message": "Ocultar avanzadas" + }, + "confirmation.cancelButton": { + "message": "Cancelar" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Desconocida" + }, + "confirmation.advanced.programId": { + "message": "ID del programa" + }, + "confirmation.advanced.data": { + "message": "Datos" + }, + "confirmation.fee": { + "message": "Tarifa de red" + }, + "confirmation.feeError": { + "message": "No se puede calcular la tarifa" + }, + "confirmation.origin": { + "message": "Solicitud de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Esta transacción se revirtió durante la simulación." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Motivo: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Esta es una solicitud engañosa" + }, + "confirmation.validationErrorSubtitle": { + "message": "Si aprueba esta solicitud, un tercero conocido por sus estafas se quedará con todos sus activos." + }, + "confirmation.validationErrorLearnMore": { + "message": "Conozca más" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Asesoramiento de seguridad por" + }, + "confirmation.signMessage.title": { + "message": "Firmar mensaje" + }, + "confirmation.signMessage.message": { + "message": "Mensaje" + }, + "confirmation.signIn.title": { + "message": "Solicitud de inicio de sesión" + }, + "confirmation.signIn.description": { + "message": "Un sitio quiere que inicie sesión para demostrar que usted es el titular de esta cuenta." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Dominio desconocido" + }, + "confirmation.signIn.signingInWith": { + "message": "Iniciar sesión con" + }, + "confirmation.signIn.badAccount": { + "message": "Este sitio le pide que inicie sesión con la cuenta incorrecta." + }, + "confirmation.signIn.message": { + "message": "Mensaje" + }, + "confirmation.signIn.statement": { + "message": "Declaración" + }, + "confirmation.signIn.version": { + "message": "Versión" + }, + "confirmation.signIn.chainId": { + "message": "ID de cadena" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Emitido a las" + }, + "confirmation.signIn.expirationTime": { + "message": "Hora de caducidad" + }, + "confirmation.signIn.notBefore": { + "message": "No antes de" + }, + "confirmation.signIn.requestId": { + "message": "ID de solicitud" + }, + "confirmation.signIn.resources": { + "message": "Recursos" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Ya existe una cuenta con la misma dirección." + }, + "transactionScan.errors.insufficientSol": { + "message": "La cuenta no tiene suficiente SOL para realizar la operación." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "La transacción se revirtió porque se superó la tolerancia de deslizamiento." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/fr.json b/merged-packages/solana-wallet-snap/locales/fr.json new file mode 100644 index 00000000..9519ccc4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/fr.json @@ -0,0 +1,269 @@ +{ + "locale": "fr", + "messages": { + "send.title": { + "message": "Envoyer" + }, + "send.balance": { + "message": "Solde" + }, + "send.maxButton": { + "message": "Maximum" + }, + "send.cancelButton": { + "message": "Annuler" + }, + "send.continueButton": { + "message": "Continuer" + }, + "send.fromField": { + "message": "Expéditeur" + }, + "send.toField": { + "message": "Destinataire" + }, + "send.amountField": { + "message": "Montant" + }, + "send.assetField": { + "message": "Jeton" + }, + "send.toPlaceholder": { + "message": "Saisissez l’adresse publique" + }, + "send.fromRequiredError": { + "message": "Le champ « Compte » ne peut pas être vide" + }, + "send.amountRequiredError": { + "message": "Le champ « Montant » ne peut pas être vide" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Le montant doit être supérieur à {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Solde de SOL insuffisant pour couvrir les frais de transaction" + }, + "send.toRequiredError": { + "message": "L’adresse du destinataire est requise" + }, + "send.toInvalidError": { + "message": "Adresse Solana non valide" + }, + "send.insufficientBalance": { + "message": "Solde insuffisant" + }, + "send.simulationTitleAPIError": { + "message": "En raison d’une erreur, nous n’avons pas pu vérifier les alertes de sécurité." + }, + "send.simulationMessageAPIError": { + "message": "Ne continuez que si vous faites confiance à toutes les adresses concernées." + }, + "send.simulationTitleError": { + "message": "La simulation de la transaction a échoué" + }, + "send.simulationMessageError": { + "message": "Cette transaction a été annulée pendant la simulation." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Le prix des jetons en {currency} n’est pas disponible pour le moment. Vous pouvez toujours envoyer des jetons." + }, + "send.confirmation.title": { + "message": "Examiner" + }, + "send.confirmation.from": { + "message": "Expéditeur" + }, + "send.confirmation.recipient": { + "message": "Destinataire" + }, + "send.confirmation.network": { + "message": "Réseau" + }, + "send.confirmation.transactionSpeed": { + "message": "Vitesse de transaction" + }, + "send.confirmation.fee": { + "message": "Frais de réseau" + }, + "send.confirmation.sendButton": { + "message": "Envoyer" + }, + "send.confirmation.cancelButton": { + "message": "Annuler" + }, + "send.confirmation.viewTransaction": { + "message": "Voir la transaction" + }, + "send.send-pending.title": { + "message": "En cours d’envoi…" + }, + "send.send-pending.subtitle": { + "message": "Votre transaction a été soumise." + }, + "send.transaction-success.title": { + "message": "Envoyé" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} a été envoyé avec succès" + }, + "send.transaction-failure.title": { + "message": "La transaction a échoué" + }, + "send.transaction-failure.subtitle": { + "message": "Impossible d’envoyer {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Compte" + }, + "confirmation.network": { + "message": "Réseau" + }, + "confirmation.estimatedChanges": { + "message": "Changements estimés" + }, + "confirmation.estimatedChanges.send": { + "message": "Vous envoyez" + }, + "confirmation.estimatedChanges.receive": { + "message": "Vous recevez" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Non disponible" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Aucun changement" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Les changements estimés représentent ce qui pourrait se produire si vous effectuez cette transaction. Il s’agit juste d’une estimation fournie à titre d’information." + }, + "confirmation.advanced.show": { + "message": "Afficher les options avancées" + }, + "confirmation.advanced.hide": { + "message": "Masquer les options avancées" + }, + "confirmation.cancelButton": { + "message": "Annuler" + }, + "confirmation.confirmButton": { + "message": "Confirmer" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Demande de transaction" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Inconnu" + }, + "confirmation.advanced.programId": { + "message": "Identifiant du programme" + }, + "confirmation.advanced.data": { + "message": "Données" + }, + "confirmation.fee": { + "message": "Frais de réseau" + }, + "confirmation.feeError": { + "message": "Impossible d’estimer les frais" + }, + "confirmation.origin": { + "message": "Demande de la part de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Cette transaction a été annulée pendant la simulation." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Motif : {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Cette demande est trompeuse" + }, + "confirmation.validationErrorSubtitle": { + "message": "Si vous approuvez cette demande, un tiers connu pour ses activités frauduleuses pourrait s’emparer de tous vos actifs." + }, + "confirmation.validationErrorLearnMore": { + "message": "En savoir plus" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Conseils de sécurité fournis par" + }, + "confirmation.signMessage.title": { + "message": "Signer le message" + }, + "confirmation.signMessage.message": { + "message": "Message" + }, + "confirmation.signIn.title": { + "message": "Demande de connexion" + }, + "confirmation.signIn.description": { + "message": "Un site vous demande de vous connecter pour prouver que vous êtes le titulaire de ce compte." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Domaine inconnu" + }, + "confirmation.signIn.signingInWith": { + "message": "Se connecter avec" + }, + "confirmation.signIn.badAccount": { + "message": "Ce site vous demande de vous connecter en utilisant le mauvais compte." + }, + "confirmation.signIn.message": { + "message": "Message" + }, + "confirmation.signIn.statement": { + "message": "Déclaration" + }, + "confirmation.signIn.version": { + "message": "Version" + }, + "confirmation.signIn.chainId": { + "message": "ID de chaîne" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Émis à" + }, + "confirmation.signIn.expirationTime": { + "message": "Délai d’expiration" + }, + "confirmation.signIn.notBefore": { + "message": "Pas avant" + }, + "confirmation.signIn.requestId": { + "message": "ID de la demande" + }, + "confirmation.signIn.resources": { + "message": "Ressources" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Un compte avec la même adresse existe déjà." + }, + "transactionScan.errors.insufficientSol": { + "message": "Le compte ne dispose pas de suffisamment de SOL pour effectuer l’opération." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "La transaction a été annulée, car la tolérance de slippage a été dépassée." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/hi.json b/merged-packages/solana-wallet-snap/locales/hi.json new file mode 100644 index 00000000..2ab7276c --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/hi.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "भेजें" + }, + "send.balance": { + "message": "बैलेंस" + }, + "send.maxButton": { + "message": "मैक्स" + }, + "send.cancelButton": { + "message": "कैंसिल करें" + }, + "send.continueButton": { + "message": "जारी रखें" + }, + "send.fromField": { + "message": "द्वारा भेजा गया" + }, + "send.toField": { + "message": "को भेजा गया" + }, + "send.amountField": { + "message": "रकम" + }, + "send.assetField": { + "message": "टोकन" + }, + "send.toPlaceholder": { + "message": "पब्लिक एड्रेस डालें" + }, + "send.fromRequiredError": { + "message": "अकाउंट होना ज़रूरी है" + }, + "send.amountRequiredError": { + "message": "अमाउंट होना ज़रूरी है" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "राशि {minimumValue} से ज़्यादा होनी चाहिए" + }, + "send.insuffientSolToCoverFee": { + "message": "ट्रांसेक्शन शुल्क को कवर करने के लिए अपर्याप्त SOL बैलेंस" + }, + "send.toRequiredError": { + "message": "जिसको भेजा जा रहा है उसका एड्रेस होना चाहिए" + }, + "send.toInvalidError": { + "message": "सोलाना का अमान्य पता" + }, + "send.insufficientBalance": { + "message": "कम बैलेंस" + }, + "send.simulationTitleAPIError": { + "message": "किसी गड़बड़ी (एरर) के कारण, हम सुरक्षा एलर्ट की जांच नहीं कर सके।" + }, + "send.simulationMessageAPIError": { + "message": "केवल तभी आगे बढ़ें जब आपको शामिल प्रत्येक एड्रेस पर भरोसा हो।" + }, + "send.simulationTitleError": { + "message": "ट्रांसेक्शन सिमुलेशन विफल रहा" + }, + "send.simulationMessageError": { + "message": "सिमुलेशन के दौरान ये ट्रांसेक्शन वापस कर दिया गया।" + }, + "send.selectedTokenPriceNotAvailable": { + "message": "{currency} में टोकनों के लिए कीमत फिलहाल उपलब्ध नहीं हैं। पर आप अभी भी टोकन भेज सकते हैं।" + }, + "send.confirmation.title": { + "message": "समीक्षा करें" + }, + "send.confirmation.from": { + "message": "द्वारा भेजा गया" + }, + "send.confirmation.recipient": { + "message": "द्वारा प्राप्त किया गया" + }, + "send.confirmation.network": { + "message": "नेटवर्क" + }, + "send.confirmation.transactionSpeed": { + "message": "ट्रांसेक्शन स्पीड" + }, + "send.confirmation.fee": { + "message": "नेटवर्क फीस" + }, + "send.confirmation.sendButton": { + "message": "भेजें" + }, + "send.confirmation.cancelButton": { + "message": "कैंसिल करें" + }, + "send.confirmation.viewTransaction": { + "message": "ट्रांसेक्शन देखें" + }, + "send.send-pending.title": { + "message": "भेजा जा रहा है..." + }, + "send.send-pending.subtitle": { + "message": "आपका ट्रांसेक्शन सबमिट किया गया।" + }, + "send.transaction-success.title": { + "message": "भेजा गया" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} सफलतापूर्वक भेजा गया" + }, + "send.transaction-failure.title": { + "message": "ट्रांसेक्शन नहीं हो पाया" + }, + "send.transaction-failure.subtitle": { + "message": "{amount} {tokenSymbol} भेज नहीं पा रहे हैं" + }, + "confirmation.account": { + "message": "अकाउंट" + }, + "confirmation.network": { + "message": "नेटवर्क" + }, + "confirmation.estimatedChanges": { + "message": "अनुमानित बदलाव" + }, + "confirmation.estimatedChanges.send": { + "message": "आप भेजते हैं" + }, + "confirmation.estimatedChanges.receive": { + "message": "आप प्राप्त करते हैं" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "उपलब्ध नहीं है" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "कोई बदलाव नहीं" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "अगर आप यह ट्रांसेक्शन करते हैं तो अनुमानित परिवर्तन हो सकते हैं। यह सिर्फ एक प्रेडिक्शन है, कोई गारंटी नहीं।" + }, + "confirmation.advanced.show": { + "message": "एडवांस्ड दिखाएं" + }, + "confirmation.advanced.hide": { + "message": "एडवांस्ड को छुपाएं" + }, + "confirmation.cancelButton": { + "message": "कैंसिल करें" + }, + "confirmation.confirmButton": { + "message": "कन्फर्म करें" + }, + "confirmation.signAndSendTransaction.title": { + "message": "ट्रांसेक्शन रिक्वेस्ट" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "अज्ञात" + }, + "confirmation.advanced.programId": { + "message": "प्रोग्राम आईडी" + }, + "confirmation.advanced.data": { + "message": "डेटा" + }, + "confirmation.fee": { + "message": "नेटवर्क फीस" + }, + "confirmation.feeError": { + "message": "फीस का अनुमान लगाने में अक्षम" + }, + "confirmation.origin": { + "message": "इनसे मिला अनुरोध" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "सिमुलेशन के दौरान ये ट्रांसेक्शन वापस कर दिया गया।" + }, + "confirmation.simulationErrorSubtitle": { + "message": "वजह: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "इस रिक्वेस्ट को धोखेबाज़ी के उद्देश्य से भेजा गया है" + }, + "confirmation.validationErrorSubtitle": { + "message": "यदि आप इस रिक्वेस्ट को एप्रूव करते हैं, तो स्कैम के लिए मशहूर कोई थर्ड पार्टी आपके सारे एसेट चुरा लेगी।" + }, + "confirmation.validationErrorLearnMore": { + "message": "ज़्यादा जानें" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "की ओर से सुरक्षा सलाह" + }, + "confirmation.signMessage.title": { + "message": "साइन मैसेज" + }, + "confirmation.signMessage.message": { + "message": "मैसेज" + }, + "confirmation.signIn.title": { + "message": "साइन-इन का अनुरोध" + }, + "confirmation.signIn.description": { + "message": "एक साइट चाहती है कि आप यह साबित करने के लिए साइन इन करें कि यह अकाउंट आपका है।" + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "अज्ञात डोमेन" + }, + "confirmation.signIn.signingInWith": { + "message": "के साथ साइन इन किया जा रहा है" + }, + "confirmation.signIn.badAccount": { + "message": "यह साइट आपसे गलत अकाउंट का उपयोग करके साइन इन करने के लिए कह रही है।" + }, + "confirmation.signIn.message": { + "message": "मैसेज" + }, + "confirmation.signIn.statement": { + "message": "कथन" + }, + "confirmation.signIn.version": { + "message": "वर्शन" + }, + "confirmation.signIn.chainId": { + "message": "चेन ID" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "में जारी किया गया" + }, + "confirmation.signIn.expirationTime": { + "message": "एक्सपायर होने का समय" + }, + "confirmation.signIn.notBefore": { + "message": "पहले नहीं" + }, + "confirmation.signIn.requestId": { + "message": "रिक्वेस्ट ID" + }, + "confirmation.signIn.resources": { + "message": "संसाधन" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "An account with the same address already exists." + }, + "transactionScan.errors.insufficientSol": { + "message": "Account does not have enough SOL to perform the operation." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "ट्रांसेक्शन को वापस कर दिया गया क्योंकि स्लिपेज (slippage) टॉलरेंस की सीमा पार हो गई थी।" + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/id.json b/merged-packages/solana-wallet-snap/locales/id.json new file mode 100644 index 00000000..47523df8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/id.json @@ -0,0 +1,269 @@ +{ + "locale": "id", + "messages": { + "send.title": { + "message": "Kirim" + }, + "send.balance": { + "message": "Saldo" + }, + "send.maxButton": { + "message": "Maks" + }, + "send.cancelButton": { + "message": "Batal" + }, + "send.continueButton": { + "message": "Lanjutkan" + }, + "send.fromField": { + "message": "Dari" + }, + "send.toField": { + "message": "Ke" + }, + "send.amountField": { + "message": "Jumlah" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Masukkan alamat publik" + }, + "send.fromRequiredError": { + "message": "Akun diperlukan" + }, + "send.amountRequiredError": { + "message": "Jumlah diperlukan" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Jumlah harus lebih besar dari {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Saldo SOL tidak cukup untuk menutupi biaya transaksi" + }, + "send.toRequiredError": { + "message": "Alamat kirim diperlukan" + }, + "send.toInvalidError": { + "message": "Alamat Solana tidak valid" + }, + "send.insufficientBalance": { + "message": "Saldo tidak cukup" + }, + "send.simulationTitleAPIError": { + "message": "Terjadi kesalahan sehingga kami tidak dapat memeriksa peringatan keamanan." + }, + "send.simulationMessageAPIError": { + "message": "Lanjutkan hanya jika Anda memercayai setiap alamat yang terlibat." + }, + "send.simulationTitleError": { + "message": "Simulasi transaksi gagal" + }, + "send.simulationMessageError": { + "message": "Transaksi ini dikembalikan selama simulasi." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Harga token dalam {currency} tidak tersedia untuk saat ini. Anda masih dapat mengirim token." + }, + "send.confirmation.title": { + "message": "Tinjau" + }, + "send.confirmation.from": { + "message": "Dari" + }, + "send.confirmation.recipient": { + "message": "Penerima" + }, + "send.confirmation.network": { + "message": "Jaringan" + }, + "send.confirmation.transactionSpeed": { + "message": "Kecepatan transaksi" + }, + "send.confirmation.fee": { + "message": "Biaya jaringan" + }, + "send.confirmation.sendButton": { + "message": "Kirim" + }, + "send.confirmation.cancelButton": { + "message": "Batal" + }, + "send.confirmation.viewTransaction": { + "message": "Lihat transaksi" + }, + "send.send-pending.title": { + "message": "Mengirim..." + }, + "send.send-pending.subtitle": { + "message": "Transaksi Anda telah dikirimkan." + }, + "send.transaction-success.title": { + "message": "Terkirim" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} berhasil dikirim" + }, + "send.transaction-failure.title": { + "message": "Transaksi gagal" + }, + "send.transaction-failure.subtitle": { + "message": "Tidak dapat mengirim {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Akun" + }, + "confirmation.network": { + "message": "Jaringan" + }, + "confirmation.estimatedChanges": { + "message": "Estimasi perubahan" + }, + "confirmation.estimatedChanges.send": { + "message": "Anda mengirim" + }, + "confirmation.estimatedChanges.receive": { + "message": "Anda menerima" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Tidak tersedia" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Tidak ada perubahan" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Estimasi perubahan merupakan hal yang mungkin terjadi jika Anda melakukan transaksi ini. Ini hanyalah prediksi, bukan jaminan." + }, + "confirmation.advanced.show": { + "message": "Tampilkan lanjutan" + }, + "confirmation.advanced.hide": { + "message": "Sembunyikan lanjutan" + }, + "confirmation.cancelButton": { + "message": "Batal" + }, + "confirmation.confirmButton": { + "message": "Konfirmasikan" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Permintaan transaksi" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Tidak dikenal" + }, + "confirmation.advanced.programId": { + "message": "ID Program" + }, + "confirmation.advanced.data": { + "message": "Data" + }, + "confirmation.fee": { + "message": "Biaya jaringan" + }, + "confirmation.feeError": { + "message": "Tidak dapat memperkirakan biaya" + }, + "confirmation.origin": { + "message": "Permintaan dari" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Transaksi ini dikembalikan selama simulasi." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Alasan: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Ini adalah permintaan tipuan" + }, + "confirmation.validationErrorSubtitle": { + "message": "Jika Anda menyetujui permintaan ini, pihak ketiga yang terdeteksi melakukan penipuan akan mengambil semua aset Anda." + }, + "confirmation.validationErrorLearnMore": { + "message": "Pelajari selengkapnya" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Saran keamanan dari" + }, + "confirmation.signMessage.title": { + "message": "Tanda tangani pesan" + }, + "confirmation.signMessage.message": { + "message": "Pesan" + }, + "confirmation.signIn.title": { + "message": "Permintaan masuk" + }, + "confirmation.signIn.description": { + "message": "Sebuah situs ingin Anda masuk untuk membuktikan bahwa Anda merupakan pemilik akun ini." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Domain tak dikenal" + }, + "confirmation.signIn.signingInWith": { + "message": "Masuk dengan" + }, + "confirmation.signIn.badAccount": { + "message": "Situs ini meminta Anda masuk menggunakan akun yang salah." + }, + "confirmation.signIn.message": { + "message": "Pesan" + }, + "confirmation.signIn.statement": { + "message": "Laporan" + }, + "confirmation.signIn.version": { + "message": "Versi" + }, + "confirmation.signIn.chainId": { + "message": "ID Chain" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Diterbitkan pada" + }, + "confirmation.signIn.expirationTime": { + "message": "Waktu kedaluwarsa" + }, + "confirmation.signIn.notBefore": { + "message": "Tidak sebelum" + }, + "confirmation.signIn.requestId": { + "message": "ID Permintaan" + }, + "confirmation.signIn.resources": { + "message": "Sumber daya" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Akun dengan alamat yang sama sudah ada." + }, + "transactionScan.errors.insufficientSol": { + "message": "Akun tidak memiliki cukup SOL untuk melakukan operasi." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Transaksi dibatalkan karena toleransi selip telah terlampaui." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/ja.json b/merged-packages/solana-wallet-snap/locales/ja.json new file mode 100644 index 00000000..6218d92d --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/ja.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "送金" + }, + "send.balance": { + "message": "残高" + }, + "send.maxButton": { + "message": "最大" + }, + "send.cancelButton": { + "message": "キャンセル" + }, + "send.continueButton": { + "message": "続行" + }, + "send.fromField": { + "message": "送り元" + }, + "send.toField": { + "message": "送り先" + }, + "send.amountField": { + "message": "金額" + }, + "send.assetField": { + "message": "トークン" + }, + "send.toPlaceholder": { + "message": "パブリックアドレスを入力してください" + }, + "send.fromRequiredError": { + "message": "アカウントが必要です" + }, + "send.amountRequiredError": { + "message": "金額が必要です" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "金額は{minimumValue}より大きくする必要があります" + }, + "send.insuffientSolToCoverFee": { + "message": "トランザクション手数料の支払いに十分なSOL残高がありません" + }, + "send.toRequiredError": { + "message": "送り先のアドレスが必要です" + }, + "send.toInvalidError": { + "message": "Solanaアドレスが無効です" + }, + "send.insufficientBalance": { + "message": "残高が不十分です" + }, + "send.simulationTitleAPIError": { + "message": "エラーのため、セキュリティアラートを確認できませんでした。" + }, + "send.simulationMessageAPIError": { + "message": "関係するすべてのアドレスを信頼できる場合のみ続行してください。" + }, + "send.simulationTitleError": { + "message": "トランザクションのシミュレーションに失敗しました" + }, + "send.simulationMessageError": { + "message": "このトランザクションはシミュレーション中に取り消されました。" + }, + "send.selectedTokenPriceNotAvailable": { + "message": "{currency}でのトークンの価格は現在利用できません。トークンの送金は可能です。" + }, + "send.confirmation.title": { + "message": "確認" + }, + "send.confirmation.from": { + "message": "送り元" + }, + "send.confirmation.recipient": { + "message": "受取人" + }, + "send.confirmation.network": { + "message": "ネットワーク" + }, + "send.confirmation.transactionSpeed": { + "message": "トランザクションの速度" + }, + "send.confirmation.fee": { + "message": "ネットワーク手数料" + }, + "send.confirmation.sendButton": { + "message": "送金" + }, + "send.confirmation.cancelButton": { + "message": "キャンセル" + }, + "send.confirmation.viewTransaction": { + "message": "トランザクションを表示" + }, + "send.send-pending.title": { + "message": "送信中..." + }, + "send.send-pending.subtitle": { + "message": "トランザクションが送信されました。" + }, + "send.transaction-success.title": { + "message": "送信完了" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol}が送金されました" + }, + "send.transaction-failure.title": { + "message": "トランザクションに失敗しました" + }, + "send.transaction-failure.subtitle": { + "message": "{amount} {tokenSymbol}を送金できません" + }, + "confirmation.account": { + "message": "アカウント" + }, + "confirmation.network": { + "message": "ネットワーク" + }, + "confirmation.estimatedChanges": { + "message": "予測される増減額" + }, + "confirmation.estimatedChanges.send": { + "message": "送金額" + }, + "confirmation.estimatedChanges.receive": { + "message": "受取額" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "利用できません" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "変化はありません" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "予測される増減額は、このトランザクションを実行すると発生する可能性がある増減額です。これは単に予測に過ぎず、保証されたものではありません。" + }, + "confirmation.advanced.show": { + "message": "高度な設定を表示" + }, + "confirmation.advanced.hide": { + "message": "高度な設定を非表示" + }, + "confirmation.cancelButton": { + "message": "キャンセル" + }, + "confirmation.confirmButton": { + "message": "確定" + }, + "confirmation.signAndSendTransaction.title": { + "message": "トランザクションリクエスト" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "不明" + }, + "confirmation.advanced.programId": { + "message": "プログラムID" + }, + "confirmation.advanced.data": { + "message": "データ" + }, + "confirmation.fee": { + "message": "ネットワーク手数料" + }, + "confirmation.feeError": { + "message": "手数料を見積もれません" + }, + "confirmation.origin": { + "message": "要求元" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "このトランザクションはシミュレーション中に取り消されました。" + }, + "confirmation.simulationErrorSubtitle": { + "message": "理由: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "これは虚偽のリクエストです" + }, + "confirmation.validationErrorSubtitle": { + "message": "このリクエストを承認すると、詐欺が判明している第三者に資産をすべて奪われます。" + }, + "confirmation.validationErrorLearnMore": { + "message": "詳細" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "セキュリティアドバイスの提供元:" + }, + "confirmation.signMessage.title": { + "message": "メッセージに署名" + }, + "confirmation.signMessage.message": { + "message": "メッセージ" + }, + "confirmation.signIn.title": { + "message": "サインイン要求" + }, + "confirmation.signIn.description": { + "message": "このアカウントの所有者であることを証明するために、サイトがサインインを求めています。" + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "不明なドメイン" + }, + "confirmation.signIn.signingInWith": { + "message": "サインイン方法" + }, + "confirmation.signIn.badAccount": { + "message": "このサイトは正しくないアカウントでのサインインを求めています。" + }, + "confirmation.signIn.message": { + "message": "メッセージ" + }, + "confirmation.signIn.statement": { + "message": "声明" + }, + "confirmation.signIn.version": { + "message": "バージョン" + }, + "confirmation.signIn.chainId": { + "message": "チェーンID" + }, + "confirmation.signIn.nonce": { + "message": "ナンス" + }, + "confirmation.signIn.issuedAt": { + "message": "発行日時" + }, + "confirmation.signIn.expirationTime": { + "message": "有効期限" + }, + "confirmation.signIn.notBefore": { + "message": "以前ではない" + }, + "confirmation.signIn.requestId": { + "message": "要求ID" + }, + "confirmation.signIn.resources": { + "message": "リソース" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "同じアドレスのアカウントがすでに存在します." + }, + "transactionScan.errors.insufficientSol": { + "message": "アカウントには、この操作を実行するのに十分なSOLがありません." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "スリッページの許容範囲を超えたため、トランザクションが取り消されました。" + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/ko.json b/merged-packages/solana-wallet-snap/locales/ko.json new file mode 100644 index 00000000..d9a32b89 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/ko.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "전송" + }, + "send.balance": { + "message": "잔액" + }, + "send.maxButton": { + "message": "최대" + }, + "send.cancelButton": { + "message": "취소" + }, + "send.continueButton": { + "message": "계속" + }, + "send.fromField": { + "message": "발신:" + }, + "send.toField": { + "message": "수신:" + }, + "send.amountField": { + "message": "금액" + }, + "send.assetField": { + "message": "토큰" + }, + "send.toPlaceholder": { + "message": "공개 주소 입력" + }, + "send.fromRequiredError": { + "message": "계정은 필수입니다" + }, + "send.amountRequiredError": { + "message": "금액은 필수입니다" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "금액은 {minimumValue}보다 커야 합니다" + }, + "send.insuffientSolToCoverFee": { + "message": "트랜잭션 수수료를 지불하기에 SOL 잔액이 부족합니다" + }, + "send.toRequiredError": { + "message": "수신 주소는 필수입니다" + }, + "send.toInvalidError": { + "message": "잘못된 Solana 주소" + }, + "send.insufficientBalance": { + "message": "잔액 부족" + }, + "send.simulationTitleAPIError": { + "message": "오류로 인해 보안 알림을 확인할 수 없습니다." + }, + "send.simulationMessageAPIError": { + "message": "모든 관련 주소를 신뢰하는 경우에만 계속 진행하세요." + }, + "send.simulationTitleError": { + "message": "트랜잭션 시뮬레이션 실패" + }, + "send.simulationMessageError": { + "message": "이 트랜잭션은 시뮬레이션 중에 이전 상태로 되돌아갔습니다." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "현재 {currency} 기준 토큰 가격을 사용할 수 없습니다. 토큰 전송은 여전히 가능합니다." + }, + "send.confirmation.title": { + "message": "검토" + }, + "send.confirmation.from": { + "message": "발신:" + }, + "send.confirmation.recipient": { + "message": "수신자" + }, + "send.confirmation.network": { + "message": "네트워크" + }, + "send.confirmation.transactionSpeed": { + "message": "트랜잭션 속도" + }, + "send.confirmation.fee": { + "message": "네트워크 수수료" + }, + "send.confirmation.sendButton": { + "message": "전송" + }, + "send.confirmation.cancelButton": { + "message": "취소" + }, + "send.confirmation.viewTransaction": { + "message": "트랜잭션 보기" + }, + "send.send-pending.title": { + "message": "전송 중..." + }, + "send.send-pending.subtitle": { + "message": "트랜잭션이 제출되었습니다." + }, + "send.transaction-success.title": { + "message": "전송 완료" + }, + "send.transaction-success.subtitle": { + "message": "{amount}{tokenSymbol}(을)를 전송했습니다" + }, + "send.transaction-failure.title": { + "message": "트랜잭션 실패" + }, + "send.transaction-failure.subtitle": { + "message": "{amount}{tokenSymbol}(을)를 전송할 수 없습니다" + }, + "confirmation.account": { + "message": "계정" + }, + "confirmation.network": { + "message": "네트워크" + }, + "confirmation.estimatedChanges": { + "message": "예상 변동 사항" + }, + "confirmation.estimatedChanges.send": { + "message": "전송:" + }, + "confirmation.estimatedChanges.receive": { + "message": "받음:" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "이용할 수 없음" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "변경 사항 없음" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "예상 변동 사항은 이 트랜잭션을 진행할 경우 발생하는 결과를 예측한 것입니다. 이는 예측일 뿐 결과를 보장하지는 않습니다." + }, + "confirmation.advanced.show": { + "message": "고급 기능 표시" + }, + "confirmation.advanced.hide": { + "message": "고급 기능 숨기기" + }, + "confirmation.cancelButton": { + "message": "취소" + }, + "confirmation.confirmButton": { + "message": "컨펌" + }, + "confirmation.signAndSendTransaction.title": { + "message": "트랜잭션 요청" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "알 수 없음" + }, + "confirmation.advanced.programId": { + "message": "프로그램 ID" + }, + "confirmation.advanced.data": { + "message": "데이터" + }, + "confirmation.fee": { + "message": "네트워크 수수료" + }, + "confirmation.feeError": { + "message": "수수료 예상 불가능" + }, + "confirmation.origin": { + "message": "요청자:" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "이 트랜잭션은 시뮬레이션 중에 이전 상태로 되돌아갔습니다." + }, + "confirmation.simulationErrorSubtitle": { + "message": "사유: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "사기성 요청입니다" + }, + "confirmation.validationErrorSubtitle": { + "message": "이 요청을 승인하면 스캠을 목적으로 하는 제3자가 회원님의 자산을 모두 가져가게 됩니다." + }, + "confirmation.validationErrorLearnMore": { + "message": "자세히 알아보기" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "보안 권고 제공:" + }, + "confirmation.signMessage.title": { + "message": "메시지 서명" + }, + "confirmation.signMessage.message": { + "message": "메시지" + }, + "confirmation.signIn.title": { + "message": "로그인 요청" + }, + "confirmation.signIn.description": { + "message": "회원님이 이 계정을 소유하고 있음을 확인하기 위해 로그인을 요청하는 사이트가 있습니다." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "알 수 없는 도메인" + }, + "confirmation.signIn.signingInWith": { + "message": "다음으로 로그인 중" + }, + "confirmation.signIn.badAccount": { + "message": "이 사이트는 잘못된 계정으로 로그인하도록 요청하고 있습니다." + }, + "confirmation.signIn.message": { + "message": "메시지" + }, + "confirmation.signIn.statement": { + "message": "확인 문구" + }, + "confirmation.signIn.version": { + "message": "버전" + }, + "confirmation.signIn.chainId": { + "message": "체인 ID" + }, + "confirmation.signIn.nonce": { + "message": "논스" + }, + "confirmation.signIn.issuedAt": { + "message": "발급 시각:" + }, + "confirmation.signIn.expirationTime": { + "message": "만료 시각:" + }, + "confirmation.signIn.notBefore": { + "message": "다음 이후:" + }, + "confirmation.signIn.requestId": { + "message": "요청 ID" + }, + "confirmation.signIn.resources": { + "message": "리소스" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "동일한 주소의 계정이 이미 존재합니다." + }, + "transactionScan.errors.insufficientSol": { + "message": "이 작업을 실행하려면 계정에 더 많은 SOL이 필요합니다." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "슬리피지 허용 범위를 초과하여 트랜잭션이 이전 상태로 되돌아갔습니다." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/pt.json b/merged-packages/solana-wallet-snap/locales/pt.json new file mode 100644 index 00000000..abf27903 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/pt.json @@ -0,0 +1,269 @@ +{ + "locale": "in", + "messages": { + "send.title": { + "message": "Enviar" + }, + "send.balance": { + "message": "Saldo" + }, + "send.maxButton": { + "message": "Máx." + }, + "send.cancelButton": { + "message": "Cancelar" + }, + "send.continueButton": { + "message": "Continuar" + }, + "send.fromField": { + "message": "De" + }, + "send.toField": { + "message": "Para" + }, + "send.amountField": { + "message": "Valor" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Digite o endereço público" + }, + "send.fromRequiredError": { + "message": "Conta obrigatória" + }, + "send.amountRequiredError": { + "message": "O valor é obrigatório" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "O valor deve ser maior que {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Saldo em SOL insuficiente para cobrir a taxa de transação" + }, + "send.toRequiredError": { + "message": "O endereço é obrigatório" + }, + "send.toInvalidError": { + "message": "Endereço Solana inválido" + }, + "send.insufficientBalance": { + "message": "Saldo insuficiente" + }, + "send.simulationTitleAPIError": { + "message": "Devido a um erro, não foi possível verificar os alertas de segurança." + }, + "send.simulationMessageAPIError": { + "message": "Continue somente se você confia em todos os endereços envolvidos." + }, + "send.simulationTitleError": { + "message": "Simulação da transação falhou" + }, + "send.simulationMessageError": { + "message": "Esta transação foi revertida durante a simulação." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Preços de tokens em {currency} não estão disponíveis atualmente. Você ainda pode enviar tokens." + }, + "send.confirmation.title": { + "message": "Revisar" + }, + "send.confirmation.from": { + "message": "De" + }, + "send.confirmation.recipient": { + "message": "Beneficiário" + }, + "send.confirmation.network": { + "message": "Rede" + }, + "send.confirmation.transactionSpeed": { + "message": "Velocidade da transação" + }, + "send.confirmation.fee": { + "message": "Taxa de rede" + }, + "send.confirmation.sendButton": { + "message": "Enviar" + }, + "send.confirmation.cancelButton": { + "message": "Cancelar" + }, + "send.confirmation.viewTransaction": { + "message": "Ver transação" + }, + "send.send-pending.title": { + "message": "Enviando..." + }, + "send.send-pending.subtitle": { + "message": "Sua transação foi enviada." + }, + "send.transaction-success.title": { + "message": "Enviada" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} foi enviado com sucesso" + }, + "send.transaction-failure.title": { + "message": "Transação falhou" + }, + "send.transaction-failure.subtitle": { + "message": "Não foi possível enviar {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Conta" + }, + "confirmation.network": { + "message": "Rede" + }, + "confirmation.estimatedChanges": { + "message": "Alterações estimadas" + }, + "confirmation.estimatedChanges.send": { + "message": "Você envia" + }, + "confirmation.estimatedChanges.receive": { + "message": "Você recebe" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Não disponível" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Nenhuma alteração" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Alterações estimadas são o que pode acontecer se você prosseguir com essa transação. Isso é apenas uma previsão, não uma garantia." + }, + "confirmation.advanced.show": { + "message": "Mostrar antecipado" + }, + "confirmation.advanced.hide": { + "message": "Ocultar antecipado" + }, + "confirmation.cancelButton": { + "message": "Cancelar" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitação de transação" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Desconhecida" + }, + "confirmation.advanced.programId": { + "message": "ID do programa" + }, + "confirmation.advanced.data": { + "message": "Dados" + }, + "confirmation.fee": { + "message": "Taxa de rede" + }, + "confirmation.feeError": { + "message": "Não foi possível estimar a taxa" + }, + "confirmation.origin": { + "message": "Solicitação de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Esta transação foi revertida durante a simulação." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Motivo: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Essa solicitação é enganosa" + }, + "confirmation.validationErrorSubtitle": { + "message": "Se você aprovar essa solicitação, algum terceiro conhecido por aplicar golpes tomará todos os seus ativos." + }, + "confirmation.validationErrorLearnMore": { + "message": "Saiba mais" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Conselhos de segurança de" + }, + "confirmation.signMessage.title": { + "message": "Assinar mensagem" + }, + "confirmation.signMessage.message": { + "message": "Mensagem" + }, + "confirmation.signIn.title": { + "message": "Solicitação de login" + }, + "confirmation.signIn.description": { + "message": "Um site deseja que você faça login para comprovar que você é o titular desta conta." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Domínio desconhecido" + }, + "confirmation.signIn.signingInWith": { + "message": "Fazendo login com" + }, + "confirmation.signIn.badAccount": { + "message": "Este site está solicitando que você faça login usando a conta incorreta." + }, + "confirmation.signIn.message": { + "message": "Mensagem" + }, + "confirmation.signIn.statement": { + "message": "Declaração" + }, + "confirmation.signIn.version": { + "message": "Versão" + }, + "confirmation.signIn.chainId": { + "message": "ID da cadeia" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Emitida às" + }, + "confirmation.signIn.expirationTime": { + "message": "Prazo de validade" + }, + "confirmation.signIn.notBefore": { + "message": "Não antes de" + }, + "confirmation.signIn.requestId": { + "message": "ID da solicitação" + }, + "confirmation.signIn.resources": { + "message": "Recursos" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Já existe uma conta com o mesmo endereço." + }, + "transactionScan.errors.insufficientSol": { + "message": "A conta não possui SOL suficiente para realizar a operação." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "A transação foi revertida porque a tolerância de slippage foi excedida." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/ru.json b/merged-packages/solana-wallet-snap/locales/ru.json new file mode 100644 index 00000000..fa5551b7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/ru.json @@ -0,0 +1,269 @@ +{ + "locale": "ru", + "messages": { + "send.title": { + "message": "Отправить" + }, + "send.balance": { + "message": "Баланс" + }, + "send.maxButton": { + "message": "Макс." + }, + "send.cancelButton": { + "message": "Отмена" + }, + "send.continueButton": { + "message": "Продолжить" + }, + "send.fromField": { + "message": "От" + }, + "send.toField": { + "message": "Кому" + }, + "send.amountField": { + "message": "Сумма" + }, + "send.assetField": { + "message": "Токен" + }, + "send.toPlaceholder": { + "message": "Введите публичный адрес" + }, + "send.fromRequiredError": { + "message": "Требуется счет" + }, + "send.amountRequiredError": { + "message": "Требуется сумма" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Сумма должна быть больше {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Недостаточный баланс SOL для покрытия комиссии за транзакцию" + }, + "send.toRequiredError": { + "message": "Требуется адрес получателя" + }, + "send.toInvalidError": { + "message": "Неверный адрес Solana" + }, + "send.insufficientBalance": { + "message": "Недостаточный баланс" + }, + "send.simulationTitleAPIError": { + "message": "Из -за ошибки мы не смогли проверить наличие оповещений о безопасности." + }, + "send.simulationMessageAPIError": { + "message": "Продолжайте только если вы доверяете каждому адресу." + }, + "send.simulationTitleError": { + "message": "Моделирование транзакции не удалось" + }, + "send.simulationMessageError": { + "message": "Эта транзакция была отменена во время моделирования." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Цены на токены в {currency} в настоящее время недоступны. Вы все еще можете отправлять токены." + }, + "send.confirmation.title": { + "message": "Проверить" + }, + "send.confirmation.from": { + "message": "От" + }, + "send.confirmation.recipient": { + "message": "Получатель" + }, + "send.confirmation.network": { + "message": "Сеть" + }, + "send.confirmation.transactionSpeed": { + "message": "Скорость транзакции" + }, + "send.confirmation.fee": { + "message": "Комиссия сети" + }, + "send.confirmation.sendButton": { + "message": "Отправить" + }, + "send.confirmation.cancelButton": { + "message": "Отмена" + }, + "send.confirmation.viewTransaction": { + "message": "Смотреть транзакцию" + }, + "send.send-pending.title": { + "message": "Отправка..." + }, + "send.send-pending.subtitle": { + "message": "Ваша транзакция отправлена." + }, + "send.transaction-success.title": { + "message": "Отправлена" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} успешно отправлено" + }, + "send.transaction-failure.title": { + "message": "Транзакция не удалась" + }, + "send.transaction-failure.subtitle": { + "message": "Невозможно отправить {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Счет" + }, + "confirmation.network": { + "message": "Сеть" + }, + "confirmation.estimatedChanges": { + "message": "Прогнозируемые изменения" + }, + "confirmation.estimatedChanges.send": { + "message": "Вы отправляете" + }, + "confirmation.estimatedChanges.receive": { + "message": "Вы получаете" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Недоступно" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Изменений нет" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Прогнозируемые изменения — это то, что может произойти, если вы завершите эту транзакцию. Это всего лишь прогноз, а не гарантия." + }, + "confirmation.advanced.show": { + "message": "Показать дополнительные" + }, + "confirmation.advanced.hide": { + "message": "Скрыть дополнительные" + }, + "confirmation.cancelButton": { + "message": "Отмена" + }, + "confirmation.confirmButton": { + "message": "Подтвердить" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Запрос транзакции" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Неизвестно" + }, + "confirmation.advanced.programId": { + "message": "Ид. программы" + }, + "confirmation.advanced.data": { + "message": "Данные" + }, + "confirmation.fee": { + "message": "Комиссия сети" + }, + "confirmation.feeError": { + "message": "Невозможно оценить комиссию" + }, + "confirmation.origin": { + "message": "Запрос от" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Эта транзакция была отменена во время моделирования." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Причина: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Это запрос с целью обмана" + }, + "confirmation.validationErrorSubtitle": { + "message": "Если вы одобрите этот запрос, третья сторона, известная мошенническим поведением, похитит все ваши активы." + }, + "confirmation.validationErrorLearnMore": { + "message": "Подробнее" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Совет по безопасности от" + }, + "confirmation.signMessage.title": { + "message": "Подписать сообщение" + }, + "confirmation.signMessage.message": { + "message": "Сообщение" + }, + "confirmation.signIn.title": { + "message": "Запрос на вход" + }, + "confirmation.signIn.description": { + "message": "Сайт хочет, чтобы вы вошли в систему, чтобы доказать, что вы являетесь владельцем этого счета." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Неизвестный домен" + }, + "confirmation.signIn.signingInWith": { + "message": "Вход через" + }, + "confirmation.signIn.badAccount": { + "message": "Этот сайт просит вас войти в систему, используя неправильный счет." + }, + "confirmation.signIn.message": { + "message": "Сообщение" + }, + "confirmation.signIn.statement": { + "message": "Выписка" + }, + "confirmation.signIn.version": { + "message": "Версия" + }, + "confirmation.signIn.chainId": { + "message": "ID блокчейна" + }, + "confirmation.signIn.nonce": { + "message": "Одноразовый код" + }, + "confirmation.signIn.issuedAt": { + "message": "Выпущено в" + }, + "confirmation.signIn.expirationTime": { + "message": "Срок экспирации" + }, + "confirmation.signIn.notBefore": { + "message": "Не раньше" + }, + "confirmation.signIn.requestId": { + "message": "ID запроса" + }, + "confirmation.signIn.resources": { + "message": "Ресурсы" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Счет с тем же адресом уже существует." + }, + "transactionScan.errors.insufficientSol": { + "message": "На счете недостаточно SOL для выполнения операции." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Транзакция возвращена, потому что толерантность к проскальзыванию превышена." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/tl.json b/merged-packages/solana-wallet-snap/locales/tl.json new file mode 100644 index 00000000..13cce81e --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/tl.json @@ -0,0 +1,269 @@ +{ + "locale": "tl", + "messages": { + "send.title": { + "message": "Ipadala" + }, + "send.balance": { + "message": "Balanse" + }, + "send.maxButton": { + "message": "Max" + }, + "send.cancelButton": { + "message": "Kanselahin" + }, + "send.continueButton": { + "message": "Magpatuloy" + }, + "send.fromField": { + "message": "Mula sa/kay" + }, + "send.toField": { + "message": "Para kay/sa" + }, + "send.amountField": { + "message": "Halaga" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Ilagay ang pampublikong address" + }, + "send.fromRequiredError": { + "message": "Kinakailangan ang account" + }, + "send.amountRequiredError": { + "message": "Kinakailangan ang halaga" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Ang halaga ay dapat na mas malaki sa {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Hindi sapat ang balanse na SOL para mabayaran ang transaksyon" + }, + "send.toRequiredError": { + "message": "Kinakailangan ang address na pupuntahan" + }, + "send.toInvalidError": { + "message": "Hindi valid na address ng Solana" + }, + "send.insufficientBalance": { + "message": "Hindi sapat ang balanse" + }, + "send.simulationTitleAPIError": { + "message": "Dahil sa isang error, hindi namin masuri kung may mga alerto sa seguridad." + }, + "send.simulationMessageAPIError": { + "message": "Magpatuloy lang kung pinagkakatiwalaan mo ang bawat address na sangkot." + }, + "send.simulationTitleError": { + "message": "Nabigo ang pag-simulate ng transaksyon" + }, + "send.simulationMessageError": { + "message": "Binawi ang transaksyong ito habang nagsa-simulate." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Kasalukuyang hindi available ang mga presyo para sa mga token sa {currency}. Makakapagpadala ka pa rin ng mga token." + }, + "send.confirmation.title": { + "message": "Suriin" + }, + "send.confirmation.from": { + "message": "Mula sa/kay" + }, + "send.confirmation.recipient": { + "message": "Tatanggap" + }, + "send.confirmation.network": { + "message": "Network" + }, + "send.confirmation.transactionSpeed": { + "message": "Bilis ng transaksyon" + }, + "send.confirmation.fee": { + "message": "Bayad sa network" + }, + "send.confirmation.sendButton": { + "message": "Ipadala" + }, + "send.confirmation.cancelButton": { + "message": "Kanselahin" + }, + "send.confirmation.viewTransaction": { + "message": "Tingnan ang transaksyon" + }, + "send.send-pending.title": { + "message": "Ipinapadala..." + }, + "send.send-pending.subtitle": { + "message": "Isinumite ang iyong transaksyon." + }, + "send.transaction-success.title": { + "message": "Ipinadala" + }, + "send.transaction-success.subtitle": { + "message": "Matagumpay na naipadala ang {amount} {tokenSymbol}" + }, + "send.transaction-failure.title": { + "message": "Nabigo ang transaksyon" + }, + "send.transaction-failure.subtitle": { + "message": "Hindi naipadala ang {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.network": { + "message": "Network" + }, + "confirmation.estimatedChanges": { + "message": "Tinatayang mga pagbabago" + }, + "confirmation.estimatedChanges.send": { + "message": "Nagpadala ka ng" + }, + "confirmation.estimatedChanges.receive": { + "message": "Nakatanggap ka ng" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Hindi available" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Walang pagbabago" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Ang tinatayang mga pagbabago ay ang maaaring mangyari kung magpapatuloy ka sa transaksyong ito. Ito ay isang hula lamang, hindi isang garantiya." + }, + "confirmation.advanced.show": { + "message": "Ipakita ang advanced" + }, + "confirmation.advanced.hide": { + "message": "Itago ang advanced" + }, + "confirmation.cancelButton": { + "message": "Kanselahin" + }, + "confirmation.confirmButton": { + "message": "Kumpirmahin" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Hiling na transaksyon" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Hindi alam" + }, + "confirmation.advanced.programId": { + "message": "ID ng Programa" + }, + "confirmation.advanced.data": { + "message": "Data" + }, + "confirmation.fee": { + "message": "Bayad sa network" + }, + "confirmation.feeError": { + "message": "Hindi matantya ang bayad" + }, + "confirmation.origin": { + "message": "Kahilingan mula sa/kay" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Binawi ang transaksyong ito habang nagsa-simulate." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Dahilan: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Isa itong mapanlinlang na kahilingan" + }, + "confirmation.validationErrorSubtitle": { + "message": "Kung aaprubahan mo ang kahilingang ito, kukunin ng third party na kilala sa mga panloloko ang lahat ng asset mo." + }, + "confirmation.validationErrorLearnMore": { + "message": "Matuto pa" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Abiso sa seguridad ng" + }, + "confirmation.signMessage.title": { + "message": "Pirmahan ang mensahe" + }, + "confirmation.signMessage.message": { + "message": "Mensahe" + }, + "confirmation.signIn.title": { + "message": "Kahilingan sa pag-sign in" + }, + "confirmation.signIn.description": { + "message": "Nais ng isang site na mag-sign in ka upang patunayan na pag-aari mo ang account na ito." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Hindi kilalang domain" + }, + "confirmation.signIn.signingInWith": { + "message": "Nagsa-sign in gamit ang" + }, + "confirmation.signIn.badAccount": { + "message": "Hinihiling ng site na ito na mag-sign in ka gamit ang maling account." + }, + "confirmation.signIn.message": { + "message": "Mensahe" + }, + "confirmation.signIn.statement": { + "message": "Pahayag" + }, + "confirmation.signIn.version": { + "message": "Bersyon" + }, + "confirmation.signIn.chainId": { + "message": "ID ng Chain" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Inisyu sa" + }, + "confirmation.signIn.expirationTime": { + "message": "Oras ng pag-expire" + }, + "confirmation.signIn.notBefore": { + "message": "Hindi lalampas sa" + }, + "confirmation.signIn.requestId": { + "message": "Humiling ng ID" + }, + "confirmation.signIn.resources": { + "message": "Mga Mapagkukunan" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "May umiiral nang account na may kaparehong address." + }, + "transactionScan.errors.insufficientSol": { + "message": "Walang sapat na SOL ang account para isagawa ang operasyong ito." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Ibinalik ang transaksyon dahil lumampas sa slippage tolerance." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/tr.json b/merged-packages/solana-wallet-snap/locales/tr.json new file mode 100644 index 00000000..3c49e027 --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/tr.json @@ -0,0 +1,269 @@ +{ + "locale": "tr", + "messages": { + "send.title": { + "message": "Gönder" + }, + "send.balance": { + "message": "Bakiye" + }, + "send.maxButton": { + "message": "Maksimum" + }, + "send.cancelButton": { + "message": "İptal" + }, + "send.continueButton": { + "message": "Devam" + }, + "send.fromField": { + "message": "Kimden" + }, + "send.toField": { + "message": "Alıcı" + }, + "send.amountField": { + "message": "Tutar" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Genel adres girin" + }, + "send.fromRequiredError": { + "message": "Hesap gereklidir" + }, + "send.amountRequiredError": { + "message": "Tutar gereklidir" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Miktar şundan fazla olmalıdır: {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "İşlem ücretini karşılamak için SOL bakiyesi yetersiz" + }, + "send.toRequiredError": { + "message": "Alıcı adres gereklidir" + }, + "send.toInvalidError": { + "message": "Solana adresi geçersiz" + }, + "send.insufficientBalance": { + "message": "Bakiye yetersiz" + }, + "send.simulationTitleAPIError": { + "message": "Bir hata nedeniyle güvenlik uyarılarını kontrol edemedik." + }, + "send.simulationMessageAPIError": { + "message": "Yalnızca ilgili her adrese güveniyorsanız devam edin." + }, + "send.simulationTitleError": { + "message": "İşlem simülasyonu başarısız oldu" + }, + "send.simulationMessageError": { + "message": "Bu işlem simülasyon sırasında geri döndürüldü." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Token fiyatları şu anda {currency} para biriminde mevcut değildir. Yine de token gönderebilirsiniz." + }, + "send.confirmation.title": { + "message": "İncele" + }, + "send.confirmation.from": { + "message": "Kimden" + }, + "send.confirmation.recipient": { + "message": "Alıcı" + }, + "send.confirmation.network": { + "message": "Ağ" + }, + "send.confirmation.transactionSpeed": { + "message": "İşlem hızı" + }, + "send.confirmation.fee": { + "message": "Ağ ücreti" + }, + "send.confirmation.sendButton": { + "message": "Gönder" + }, + "send.confirmation.cancelButton": { + "message": "İptal" + }, + "send.confirmation.viewTransaction": { + "message": "İşlemi görüntüle" + }, + "send.send-pending.title": { + "message": "Gönderiliyor..." + }, + "send.send-pending.subtitle": { + "message": "İşleminiz gönderildi." + }, + "send.transaction-success.title": { + "message": "Gönderildi" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} başarılı bir şekilde gönderildi" + }, + "send.transaction-failure.title": { + "message": "İşlem başarısız oldu" + }, + "send.transaction-failure.subtitle": { + "message": "{amount} {tokenSymbol} gönderilemiyor" + }, + "confirmation.account": { + "message": "Hesap" + }, + "confirmation.network": { + "message": "Ağ" + }, + "confirmation.estimatedChanges": { + "message": "Tahmini değişiklikler" + }, + "confirmation.estimatedChanges.send": { + "message": "Gönderdiğiniz" + }, + "confirmation.estimatedChanges.receive": { + "message": "Aldığınız" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Mevcut değil" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Değişiklik yok" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Tahmini değişiklikler bu işlemi gerçekleştirirseniz meydana gelebilecek değişikliklerdir. Bu bir garanti değil, sadece bir tahmindir." + }, + "confirmation.advanced.show": { + "message": "Gelişmişi göster" + }, + "confirmation.advanced.hide": { + "message": "Gelişmişi gizle" + }, + "confirmation.cancelButton": { + "message": "İptal" + }, + "confirmation.confirmButton": { + "message": "Onayla" + }, + "confirmation.signAndSendTransaction.title": { + "message": "İşlem talebi" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Bilinmeyen" + }, + "confirmation.advanced.programId": { + "message": "Program Kimliği" + }, + "confirmation.advanced.data": { + "message": "Veriler" + }, + "confirmation.fee": { + "message": "Ağ ücreti" + }, + "confirmation.feeError": { + "message": "Ücret tahmin edilemiyor" + }, + "confirmation.origin": { + "message": "Talebi gönderen" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Bu işlem simülasyon sırasında geri döndürüldü." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Sebebi: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Bu, aldatıcı bir taleptir" + }, + "confirmation.validationErrorSubtitle": { + "message": "Bu talebi onaylarsanız dolandırıcılıkla ünlü üçüncü bir taraf tüm varlıklarınızı çalar." + }, + "confirmation.validationErrorLearnMore": { + "message": "Daha fazla bilgi" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Güvenlik önerisi:" + }, + "confirmation.signMessage.title": { + "message": "Mesajı imzala" + }, + "confirmation.signMessage.message": { + "message": "Mesaj" + }, + "confirmation.signIn.title": { + "message": "Giriş talebi" + }, + "confirmation.signIn.description": { + "message": "Bir site, bu hesabın size ait olduğunu kanıtlamanız için giriş yapmanızı istiyor." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Bilinmeyen alan" + }, + "confirmation.signIn.signingInWith": { + "message": "Şununla giriş yapılıyor:" + }, + "confirmation.signIn.badAccount": { + "message": "Bu site sizden yanlış hesabı kullanarak giriş yapmanızı istiyor." + }, + "confirmation.signIn.message": { + "message": "Mesaj" + }, + "confirmation.signIn.statement": { + "message": "Beyan" + }, + "confirmation.signIn.version": { + "message": "Sürüm" + }, + "confirmation.signIn.chainId": { + "message": "Zincir Kimliği" + }, + "confirmation.signIn.nonce": { + "message": "Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Düzenlenme tarihi:" + }, + "confirmation.signIn.expirationTime": { + "message": "Son geçerlilik tarihi:" + }, + "confirmation.signIn.notBefore": { + "message": "En erken" + }, + "confirmation.signIn.requestId": { + "message": "Talep Kimliği" + }, + "confirmation.signIn.resources": { + "message": "Kaynaklar" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Halihazırda aynı adrese sahip bir hesap mevcut." + }, + "transactionScan.errors.insufficientSol": { + "message": "İşlemi gerçekleştirmek için hesapta yeterli SOL yok." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Kayma toleransı aşıldığı için işlem geri döndürüldü." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/vi.json b/merged-packages/solana-wallet-snap/locales/vi.json new file mode 100644 index 00000000..c5aae75e --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/vi.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "Gửi" + }, + "send.balance": { + "message": "Số dư" + }, + "send.maxButton": { + "message": "Tối đa" + }, + "send.cancelButton": { + "message": "Hủy" + }, + "send.continueButton": { + "message": "Tiếp tục" + }, + "send.fromField": { + "message": "Từ" + }, + "send.toField": { + "message": "Đến" + }, + "send.amountField": { + "message": "Số lượng" + }, + "send.assetField": { + "message": "Token" + }, + "send.toPlaceholder": { + "message": "Nhập địa chỉ công khai" + }, + "send.fromRequiredError": { + "message": "Bắt buộc nhập tài khoản" + }, + "send.amountRequiredError": { + "message": "Bắt buộc nhập số lượng" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "Số lượng phải lớn hơn {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "Số dư SOL không đủ để thanh toán phí giao dịch" + }, + "send.toRequiredError": { + "message": "Bắt buộc nhập địa chỉ gửi đến" + }, + "send.toInvalidError": { + "message": "Địa chỉ Solana không hợp lệ" + }, + "send.insufficientBalance": { + "message": "Không đủ số dư" + }, + "send.simulationTitleAPIError": { + "message": "Đã xảy ra lỗi, do đó chúng tôi không thể kiểm tra các cảnh báo bảo mật." + }, + "send.simulationMessageAPIError": { + "message": "Chỉ tiếp tục nếu bạn tin tưởng mọi địa chỉ liên quan." + }, + "send.simulationTitleError": { + "message": "Mô phỏng giao dịch không thành công" + }, + "send.simulationMessageError": { + "message": "Giao dịch này đã được hoàn lại trong quá trình mô phỏng." + }, + "send.selectedTokenPriceNotAvailable": { + "message": "Giá cho token này bằng {currency} hiện không có sẵn. Bạn vẫn có thể gửi token." + }, + "send.confirmation.title": { + "message": "Xem lại" + }, + "send.confirmation.from": { + "message": "Từ" + }, + "send.confirmation.recipient": { + "message": "Người nhận" + }, + "send.confirmation.network": { + "message": "Mạng" + }, + "send.confirmation.transactionSpeed": { + "message": "Tốc độ giao dịch" + }, + "send.confirmation.fee": { + "message": "Phí mạng" + }, + "send.confirmation.sendButton": { + "message": "Gửi" + }, + "send.confirmation.cancelButton": { + "message": "Hủy" + }, + "send.confirmation.viewTransaction": { + "message": "Xem giao dịch" + }, + "send.send-pending.title": { + "message": "Đang gửi..." + }, + "send.send-pending.subtitle": { + "message": "Giao dịch của bạn đã được gửi." + }, + "send.transaction-success.title": { + "message": "Đã gửi" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} đã được gửi thành công" + }, + "send.transaction-failure.title": { + "message": "Giao dịch không thành công" + }, + "send.transaction-failure.subtitle": { + "message": "Không thể gửi {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "Tài khoản" + }, + "confirmation.network": { + "message": "Mạng" + }, + "confirmation.estimatedChanges": { + "message": "Thay đổi ước tính" + }, + "confirmation.estimatedChanges.send": { + "message": "Bạn gửi" + }, + "confirmation.estimatedChanges.receive": { + "message": "Bạn nhận được" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "Không có sẵn" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "Không có thay đổi" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Thay đổi ước tính là những gì có thể xảy ra nếu bạn thực hiện giao dịch này. Đây chỉ là dự đoán, không phải là đảm bảo." + }, + "confirmation.advanced.show": { + "message": "Hiển thị nâng cao" + }, + "confirmation.advanced.hide": { + "message": "Ẩn nâng cao" + }, + "confirmation.cancelButton": { + "message": "Hủy" + }, + "confirmation.confirmButton": { + "message": "Xác nhận" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Yêu cầu giao dịch" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "Không xác định" + }, + "confirmation.advanced.programId": { + "message": "ID chương trình" + }, + "confirmation.advanced.data": { + "message": "Dữ liệu" + }, + "confirmation.fee": { + "message": "Phí mạng" + }, + "confirmation.feeError": { + "message": "Không thể ước tính phí" + }, + "confirmation.origin": { + "message": "Yêu cầu từ" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "Giao dịch này đã được hoàn lại trong quá trình mô phỏng." + }, + "confirmation.simulationErrorSubtitle": { + "message": "Lý do: {reason}" + }, + "confirmation.validationErrorTitle": { + "message": "Đây là một yêu cầu lừa đảo" + }, + "confirmation.validationErrorSubtitle": { + "message": "Nếu bạn chấp thuận yêu cầu này, một bên thứ ba nổi tiếng là lừa đảo sẽ lấy hết tài sản của bạn." + }, + "confirmation.validationErrorLearnMore": { + "message": "Tìm hiểu thêm" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "Lời khuyên bảo mật bởi" + }, + "confirmation.signMessage.title": { + "message": "Ký tin nhắn" + }, + "confirmation.signMessage.message": { + "message": "Tin nhắn" + }, + "confirmation.signIn.title": { + "message": "Yêu cầu đăng nhập" + }, + "confirmation.signIn.description": { + "message": "Trang web muốn bạn đăng nhập để chứng minh bạn sở hữu tài khoản này." + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "Miền không xác định" + }, + "confirmation.signIn.signingInWith": { + "message": "Đăng nhập bằng" + }, + "confirmation.signIn.badAccount": { + "message": "Trang web này yêu cầu bạn đăng nhập bằng tài khoản không đúng." + }, + "confirmation.signIn.message": { + "message": "Tin nhắn" + }, + "confirmation.signIn.statement": { + "message": "Tuyên bố" + }, + "confirmation.signIn.version": { + "message": "Phiên bản" + }, + "confirmation.signIn.chainId": { + "message": "ID Chuỗi" + }, + "confirmation.signIn.nonce": { + "message": "Số Nonce" + }, + "confirmation.signIn.issuedAt": { + "message": "Phát hành vào" + }, + "confirmation.signIn.expirationTime": { + "message": "Thời gian hết hạn" + }, + "confirmation.signIn.notBefore": { + "message": "Không trước" + }, + "confirmation.signIn.requestId": { + "message": "ID yêu cầu" + }, + "confirmation.signIn.resources": { + "message": "Tài nguyên" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "Đã tồn tại tài khoản với cùng địa chỉ này." + }, + "transactionScan.errors.insufficientSol": { + "message": "Tài khoản không có đủ SOL để thực hiện tác vụ." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "Giao dịch đã được hoàn tác vì vượt quá ngưỡng trượt giá cho phép." + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/locales/zh.json b/merged-packages/solana-wallet-snap/locales/zh.json new file mode 100644 index 00000000..51d014ca --- /dev/null +++ b/merged-packages/solana-wallet-snap/locales/zh.json @@ -0,0 +1,269 @@ +{ + "locale": "en", + "messages": { + "send.title": { + "message": "发送" + }, + "send.balance": { + "message": "余额" + }, + "send.maxButton": { + "message": "最大值" + }, + "send.cancelButton": { + "message": "取消" + }, + "send.continueButton": { + "message": "继续" + }, + "send.fromField": { + "message": "从" + }, + "send.toField": { + "message": "至" + }, + "send.amountField": { + "message": "金额" + }, + "send.assetField": { + "message": "代币" + }, + "send.toPlaceholder": { + "message": "输入公共地址" + }, + "send.fromRequiredError": { + "message": "账户为必填项" + }, + "send.amountRequiredError": { + "message": "金额为必填项" + }, + "send.amountGreatherThanMinimumBalanceForRentExemptionError": { + "message": "金额必须大于 {minimumValue}" + }, + "send.insuffientSolToCoverFee": { + "message": "SOL 余额不足,无法支付交易费" + }, + "send.toRequiredError": { + "message": "接收者地址为必填项" + }, + "send.toInvalidError": { + "message": "Solana 地址无效" + }, + "send.insufficientBalance": { + "message": "余额不足" + }, + "send.simulationTitleAPIError": { + "message": "由于错误,我们无法检查安全警报。" + }, + "send.simulationMessageAPIError": { + "message": "请仅在信任所有相关地址的前提下继续操作。" + }, + "send.simulationTitleError": { + "message": "交易模拟失败" + }, + "send.simulationMessageError": { + "message": "此交易在模拟期间被撤销。" + }, + "send.selectedTokenPriceNotAvailable": { + "message": "目前无法获取以 {currency} 计价的代币价格。您仍然可以发送代币。" + }, + "send.confirmation.title": { + "message": "查看" + }, + "send.confirmation.from": { + "message": "从" + }, + "send.confirmation.recipient": { + "message": "接收者" + }, + "send.confirmation.network": { + "message": "网络" + }, + "send.confirmation.transactionSpeed": { + "message": "交易速度" + }, + "send.confirmation.fee": { + "message": "网络费" + }, + "send.confirmation.sendButton": { + "message": "发送" + }, + "send.confirmation.cancelButton": { + "message": "取消" + }, + "send.confirmation.viewTransaction": { + "message": "查看交易" + }, + "send.send-pending.title": { + "message": "正在发送......" + }, + "send.send-pending.subtitle": { + "message": "您的交易已提交。" + }, + "send.transaction-success.title": { + "message": "已发送" + }, + "send.transaction-success.subtitle": { + "message": "{amount} {tokenSymbol} 已成功发送" + }, + "send.transaction-failure.title": { + "message": "交易失败" + }, + "send.transaction-failure.subtitle": { + "message": "无法发送 {amount} {tokenSymbol}" + }, + "confirmation.account": { + "message": "账户" + }, + "confirmation.network": { + "message": "网络" + }, + "confirmation.estimatedChanges": { + "message": "预计变化" + }, + "confirmation.estimatedChanges.send": { + "message": "您发送" + }, + "confirmation.estimatedChanges.receive": { + "message": "您收到" + }, + "confirmation.estimatedChanges.notAvailable": { + "message": "不可用" + }, + "confirmation.estimatedChanges.noChanges": { + "message": "无变化" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "预期变化是指您完成该交易可能发生的变化。这只是预测,而不是保证。" + }, + "confirmation.advanced.show": { + "message": "显示高级选项" + }, + "confirmation.advanced.hide": { + "message": "隐藏高级选项" + }, + "confirmation.cancelButton": { + "message": "取消" + }, + "confirmation.confirmButton": { + "message": "确认" + }, + "confirmation.signAndSendTransaction.title": { + "message": "交易请求" + }, + "confirmation.signTransaction.title": { + "message": "Signing request" + }, + "confirmation.sendAndConfirmTransaction.title": { + "message": "Sending and confirming transaction" + }, + "confirmation.advanced.unknownInstruction": { + "message": "未知" + }, + "confirmation.advanced.programId": { + "message": "程序 ID" + }, + "confirmation.advanced.data": { + "message": "数据" + }, + "confirmation.fee": { + "message": "网络费" + }, + "confirmation.feeError": { + "message": "无法估算费用" + }, + "confirmation.origin": { + "message": "请求来自" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.simulationErrorTitle": { + "message": "此交易在模拟期间被撤销。" + }, + "confirmation.simulationErrorSubtitle": { + "message": "原因:{reason}" + }, + "confirmation.validationErrorTitle": { + "message": "此请求属欺骗性质" + }, + "confirmation.validationErrorSubtitle": { + "message": "如果您批准此请求,因欺诈而恶名昭彰的第三方会拿走您的所有资产。" + }, + "confirmation.validationErrorLearnMore": { + "message": "了解详情" + }, + "confirmation.validationErrorSecurityAdviced": { + "message": "安全建议提供商:" + }, + "confirmation.signMessage.title": { + "message": "签署消息" + }, + "confirmation.signMessage.message": { + "message": "消息" + }, + "confirmation.signIn.title": { + "message": "登录请求" + }, + "confirmation.signIn.description": { + "message": "网站要求您登录以验证账户所有权。" + }, + "confirmation.signIn.domain": { + "message": "Domain" + }, + "confirmation.signIn.unknownDomain": { + "message": "未知域名" + }, + "confirmation.signIn.signingInWith": { + "message": "使用以下方式登录" + }, + "confirmation.signIn.badAccount": { + "message": "此网站要求您使用错误的账户登录。" + }, + "confirmation.signIn.message": { + "message": "消息" + }, + "confirmation.signIn.statement": { + "message": "声明" + }, + "confirmation.signIn.version": { + "message": "版本" + }, + "confirmation.signIn.chainId": { + "message": "链 ID" + }, + "confirmation.signIn.nonce": { + "message": "唯一交易标识号" + }, + "confirmation.signIn.issuedAt": { + "message": "签发时间:" + }, + "confirmation.signIn.expirationTime": { + "message": "有效期" + }, + "confirmation.signIn.notBefore": { + "message": "不早于" + }, + "confirmation.signIn.requestId": { + "message": "请求 ID" + }, + "confirmation.signIn.resources": { + "message": "资源" + }, + "confirmation.signIn.badDomain": { + "message": "This site is asking you to sign in using the wrong domain." + }, + "transactionScan.errors.accountAlreadyInUse": { + "message": "已存在使用相同地址的账户." + }, + "transactionScan.errors.insufficientSol": { + "message": "账户 SOL 余额不足,无法执行该操作." + }, + "transactionScan.errors.slippageToleranceExceeded": { + "message": "该交易因超出滑点容差上限而撤销。" + }, + "transactionScan.errors.unknownError": { + "message": "An unknown error occurred." + } + } +} diff --git a/merged-packages/solana-wallet-snap/messages.json b/merged-packages/solana-wallet-snap/messages.json new file mode 100644 index 00000000..5f8558d0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/messages.json @@ -0,0 +1,96 @@ +{ + "send.title": "Send", + "send.balance": "Balance", + "send.maxButton": "Max", + "send.cancelButton": "Cancel", + "send.continueButton": "Continue", + "send.fromField": "From", + "send.toField": "To", + "send.amountField": "Amount", + "send.assetField": "Token", + "send.toPlaceholder": "Enter public address or domain name", + "send.fromRequiredError": "Account is required", + "send.amountRequiredError": "Amount is required", + "send.amountGreatherThanMinimumBalanceForRentExemptionError": "Amount must be greater than {minimumValue}", + "send.insuffientSolToCoverFee": "Insufficient SOL balance to cover the transaction fee", + "send.toRequiredError": "To address is required", + "send.toInvalidError": "Invalid Solana address or domain name", + "send.toInvalidErrorDomain": "Unable to resolve domain name", + "send.toDomainResolutionStatus.initial": "Domain", + "send.toDomainResolutionStatus.fetching": "Resolving domain name...", + "send.toDomainResolutionStatus.fetched": "Domain name resolved.", + "send.toDomainResolutionStatus.error": "Unable to resolve domain name.", + "send.insufficientBalance": "Insufficient balance", + "send.simulationTitleAPIError": "Because of an error, we couldn't check for security alerts.", + "send.simulationMessageAPIError": "Only continue if you trust every address involved.", + "send.simulationTitleError": "Transaction simulation failed", + "send.simulationMessageError": "This transaction was reverted during simulation.", + "send.selectedTokenPriceNotAvailable": "Prices for tokens in {currency} are currently not available. You are still able to send tokens.", + "send.confirmation.title": "Review", + "send.confirmation.from": "From", + "send.confirmation.recipient": "Recipient", + "send.confirmation.network": "Network", + "send.confirmation.transactionSpeed": "Transaction speed", + "send.confirmation.fee": "Network fee", + "send.confirmation.sendButton": "Send", + "send.confirmation.cancelButton": "Cancel", + "send.confirmation.viewTransaction": "View transaction", + "send.send-pending.title": "Sending...", + "send.send-pending.subtitle": "Your transaction was submitted.", + "send.transaction-success.title": "Sent", + "send.transaction-success.subtitle": "{amount} {tokenSymbol} was successfully sent", + "send.transaction-failure.title": "Transaction failed", + "send.transaction-failure.subtitle": "Unable to send {amount} {tokenSymbol}", + "confirmation.account": "Account", + "confirmation.recipient": "Recipient", + "confirmation.network": "Network", + "confirmation.estimatedChanges": "Estimated changes", + "confirmation.estimatedChanges.send": "You send", + "confirmation.estimatedChanges.receive": "You receive", + "confirmation.estimatedChanges.notAvailable": "Not available", + "confirmation.estimatedChanges.noChanges": "No changes", + "confirmation.estimatedChanges.tooltip": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", + "confirmation.advanced.show": "Show advanced", + "confirmation.advanced.hide": "Hide advanced", + "confirmation.cancelButton": "Cancel", + "confirmation.confirmButton": "Confirm", + "confirmation.signAndSendTransaction.title": "Transaction request", + "confirmation.signTransaction.title": "Signing request", + "confirmation.sendAndConfirmTransaction.title": "Sending and confirming transaction", + "confirmation.advanced.unknownInstruction": "Unknown", + "confirmation.advanced.programId": "Program ID", + "confirmation.advanced.data": "Data", + "confirmation.fee": "Network fee", + "confirmation.feeError": "Unable to estimate fee", + "confirmation.origin": "Request from", + "confirmation.origin.tooltip": "This is the site asking for your confirmation.", + "confirmation.simulationErrorTitle": "This transaction was reverted during simulation.", + "confirmation.simulationErrorSubtitle": "{reason}", + "confirmation.validationErrorTitle": "This is a deceptive request", + "confirmation.validationErrorSubtitle": "If you approve this request, a third party known for scams will take all your assets.", + "confirmation.validationErrorLearnMore": "Learn more", + "confirmation.validationErrorSecurityAdviced": "Security advice by", + "confirmation.signMessage.title": "Sign message", + "confirmation.signMessage.message": "Message", + "confirmation.signIn.title": "Sign-in request", + "confirmation.signIn.description": "A site wants you to sign in to prove you own this account.", + "confirmation.signIn.domain": "Domain", + "confirmation.signIn.unknownDomain": "Unknown domain", + "confirmation.signIn.signingInWith": "Signing in with", + "confirmation.signIn.badAccount": "This site is asking you to sign in using the wrong account.", + "confirmation.signIn.message": "Message", + "confirmation.signIn.statement": "Statement", + "confirmation.signIn.version": "Version", + "confirmation.signIn.chainId": "Chain ID", + "confirmation.signIn.nonce": "Nonce", + "confirmation.signIn.issuedAt": "Issued at", + "confirmation.signIn.expirationTime": "Expiration time", + "confirmation.signIn.notBefore": "Not before", + "confirmation.signIn.requestId": "Request ID", + "confirmation.signIn.resources": "Resources", + "confirmation.signIn.badDomain": "This site is asking you to sign in using the wrong domain.", + "transactionScan.errors.accountAlreadyInUse": "An account with the same address already exists.", + "transactionScan.errors.insufficientSol": "Account does not have enough SOL to perform the operation.", + "transactionScan.errors.slippageToleranceExceeded": "The transaction was reverted because the slippage tolerance was exceeded.", + "transactionScan.errors.unknownError": "An unknown error occurred." +} diff --git a/merged-packages/solana-wallet-snap/openrpc.json b/merged-packages/solana-wallet-snap/openrpc.json new file mode 100644 index 00000000..c0c327c8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/openrpc.json @@ -0,0 +1,1083 @@ +{ + "openrpc": "1.2.4", + "info": { + "title": "MetaMask Solana Wallet Snap", + "description": "JSON-RPC API of the MetaMask Solana Wallet Snap. This snap brings official Solana support to MetaMask. Create accounts, check balances, and use Solana dApps right from your MetaMask wallet. Simple, secure, and seamless.", + "version": "1.0.0", + "contact": { + "name": "New Networks Team" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "name": "MetaMask Solana Wallet Snap", + "url": "snap://npm:@metamask/solana-wallet-snap" + } + ], + "methods": [ + { + "name": "signAndSendTransactionWithoutConfirmation", + "summary": "Sign and send a transaction without showing a confirmation dialog", + "description": "Allows the client (MetaMask) to request the snap to sign and send a Solana transaction without showing a confirmation dialog to the user. This method validates the request parameters, finds the account in the keyring, signs the transaction, and sends it to the Solana network. **Note: This method is deprecated and will be removed in the next major version.**", + "paramStructure": "by-name", + "params": [ + { + "name": "account", + "summary": "The account to use for signing the transaction", + "required": true, + "schema": { + "$ref": "#/components/schemas/WalletAccount" + } + }, + { + "name": "transaction", + "summary": "The base64-encoded transaction to sign and send", + "required": true, + "schema": { + "$ref": "#/components/schemas/Base64String" + } + }, + { + "name": "scope", + "summary": "The Solana network to send the transaction to", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + }, + { + "name": "options", + "summary": "Optional transaction options", + "required": false, + "schema": { + "$ref": "#/components/schemas/TransactionOptions" + } + } + ], + "result": { + "name": "SignAndSendTransactionResult", + "summary": "The result of signing and sending a transaction", + "schema": { + "$ref": "#/components/schemas/SignAndSendTransactionResult" + } + }, + "examples": [ + { + "name": "signAndSendTransactionExample", + "description": "Example of signing and sending a simple SOL transfer transaction", + "params": [ + { + "name": "account", + "value": { + "address": "BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP" + } + }, + { + "name": "transaction", + "value": "gAEAAgSZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdN2zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAHWszLmyDo8VIk2P/sVmDUn34YE2+73fS1kNLCNojDEqAwMABQIsAQAAAwAJA+gDAAAAAAAAAgIAAQwCAAAAQEIPAAAAAAAA" + }, + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + }, + { + "name": "options", + "value": { + "commitment": "confirmed", + "skipPreflight": false, + "maxRetries": 3 + } + } + ], + "result": { + "name": "signAndSendTransactionResult", + "value": { + "signature": "2jy9nsDuajiPgRRijs7Ku4JVvTFQ224Nhoc58fe72tRoy384JAF6zVWFV2SwTt9XXykxes6LkU6VLokn6wAXTocQ" + } + } + } + ] + }, + { + "name": "signAndSendTransaction", + "summary": "Sign and send a transaction", + "description": "Allows the client (MetaMask) to request the snap to sign and send a Solana transaction. This method validates the request parameters, finds the account in the keyring, signs the transaction, and sends it to the Solana network.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "summary": "The UUID of the account to use for signing the transaction", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "transaction", + "summary": "The base64-encoded transaction to sign and send", + "required": true, + "schema": { + "$ref": "#/components/schemas/Base64String" + } + }, + { + "name": "scope", + "summary": "The Solana network to send the transaction to", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + }, + { + "name": "options", + "summary": "Optional transaction options", + "required": false, + "schema": { + "$ref": "#/components/schemas/TransactionOptions" + } + } + ], + "result": { + "name": "SignAndSendTransactionResult", + "summary": "The result of signing and sending a transaction", + "schema": { + "type": "object", + "properties": { + "transactionId": { + "$ref": "#/components/schemas/Base58String", + "description": "The transaction signature returned after successful execution on Solana" + } + }, + "required": ["transactionId"], + "additionalProperties": false + } + }, + "examples": [ + { + "name": "signAndSendTransactionExample", + "description": "Example of signing and sending a simple SOL transfer transaction", + "params": [ + { + "name": "accountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "transaction", + "value": "gAEAAgSZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdN2zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAHWszLmyDo8VIk2P/sVmDUn34YE2+73fS1kNLCNojDEqAwMABQIsAQAAAwAJA+gDAAAAAAAAAgIAAQwCAAAAQEIPAAAAAAAA" + }, + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + }, + { + "name": "options", + "value": { + "commitment": "confirmed", + "skipPreflight": false, + "maxRetries": 3 + } + } + ], + "result": { + "name": "signAndSendTransactionResult", + "value": { + "transactionId": "2jy9nsDuajiPgRRijs7Ku4JVvTFQ224Nhoc58fe72tRoy384JAF6zVWFV2SwTt9XXykxes6LkU6VLokn6wAXTocQ" + } + } + } + ] + }, + { + "name": "confirmSend", + "summary": "Confirm and execute a send transaction", + "description": "Confirms and executes a send transaction with the specified parameters. This method validates the request parameters and submits the transaction to the Solana network.", + "paramStructure": "by-name", + "params": [ + { + "name": "fromAccountId", + "summary": "The UUID of the account to send from", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "toAddress", + "summary": "The Solana address to send to", + "required": true, + "schema": { + "$ref": "#/components/schemas/SolanaAddress" + } + }, + { + "name": "amount", + "summary": "The amount to send as a positive number string", + "required": true, + "schema": { + "$ref": "#/components/schemas/PositiveNumberString" + } + }, + { + "name": "assetId", + "summary": "The CAIP-19 asset identifier", + "required": true, + "schema": { + "$ref": "#/components/schemas/CaipAssetType" + } + } + ], + "result": { + "name": "ConfirmSendResult", + "summary": "The result of confirming and executing the send transaction", + "schema": { + "$ref": "#/components/schemas/SignAndSendTransactionResult" + } + }, + "examples": [ + { + "name": "confirmSendExample", + "description": "Example of confirming a SOL send transaction", + "params": [ + { + "name": "fromAccountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "toAddress", + "value": "BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP" + }, + { + "name": "amount", + "value": "1.5" + }, + { + "name": "assetId", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" + } + ], + "result": { + "name": "confirmSendResult", + "value": { + "signature": "2jy9nsDuajiPgRRijs7Ku4JVvTFQ224Nhoc58fe72tRoy384JAF6zVWFV2SwTt9XXykxes6LkU6VLokn6wAXTocQ" + } + } + } + ] + }, + { + "name": "computeFee", + "summary": "Compute the fee for a transaction", + "description": "Computes and returns the fee for a given transaction. This method helps users understand the cost of their transaction before execution.", + "paramStructure": "by-name", + "params": [ + { + "name": "transaction", + "summary": "The base64-encoded transaction to calculate fees for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "summary": "The Solana network scope", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + } + ], + "result": { + "name": "ComputeFeeResult", + "summary": "The computed fee information", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["base"] + }, + "asset": { + "type": "object", + "properties": { + "unit": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "fungible": { + "type": "boolean" + } + }, + "required": ["unit", "type", "amount", "fungible"] + } + }, + "required": ["type", "asset"] + } + } + }, + "examples": [ + { + "name": "computeFeeExample", + "description": "Example of computing the fee for a transaction", + "params": [ + { + "name": "transaction", + "value": "gAEAAgSZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdN2zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAHWszLmyDo8VIk2P/sVmDUn34YE2+73fS1kNLCNojDEqAwMABQIsAQAAAwAJA+gDAAAAAAAAAgIAAQwCAAAAQEIPAAAAAAAA" + }, + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + } + ], + "result": { + "name": "computeFeeResult", + "value": [ + { + "type": "base", + "asset": { + "unit": "SOL", + "type": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "amount": "0.000015", + "fungible": true + } + } + ] + } + } + ] + }, + { + "name": "onAddressInput", + "summary": "Validate a Solana address input", + "description": "Validates a Solana address input and returns validation results. This method checks if the provided address is a valid Solana address format.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "summary": "The address string to validate", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "ValidationResponse", + "summary": "The validation result for the address input", + "schema": { + "$ref": "#/components/schemas/ValidationResponse" + } + }, + "examples": [ + { + "name": "onAddressInputExample", + "description": "Example of validating a valid Solana address", + "params": [ + { + "name": "value", + "value": "BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP" + } + ], + "result": { + "name": "validationResponse", + "value": { + "valid": true, + "errors": [] + } + } + } + ] + }, + { + "name": "onAmountInput", + "summary": "Validate an amount input for a transaction", + "description": "Validates an amount input for a transaction and returns validation results. This method checks if the amount is valid based on account balance, fees, and other constraints.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "summary": "The amount string to validate", + "required": true, + "schema": { + "$ref": "#/components/schemas/PositiveNumberString" + } + }, + { + "name": "accountId", + "summary": "The UUID of the account to validate against", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "assetId", + "summary": "The CAIP-19 asset identifier to validate against", + "required": true, + "schema": { + "$ref": "#/components/schemas/CaipAssetType" + } + } + ], + "result": { + "name": "ValidationResponse", + "summary": "The validation result for the amount input", + "schema": { + "$ref": "#/components/schemas/ValidationResponse" + } + }, + "examples": [ + { + "name": "onAmountInputExample", + "description": "Example of validating a valid amount input", + "params": [ + { + "name": "value", + "value": "1.5" + }, + { + "name": "accountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "assetId", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" + } + ], + "result": { + "name": "validationResponse", + "value": { + "valid": true, + "errors": [] + } + } + } + ] + }, + { + "name": "startSendTransactionFlow", + "summary": "Start the send transaction flow interface", + "description": "Initiates the send transaction flow by rendering the send form interface. This method creates a user interface for sending transactions.", + "paramStructure": "by-name", + "params": [ + { + "name": "scope", + "summary": "The Solana network scope", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + }, + { + "name": "account", + "summary": "The UUID of the account to send from", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "assetId", + "summary": "Optional CAIP-19 asset identifier", + "required": false, + "schema": { + "$ref": "#/components/schemas/CaipAssetType" + } + } + ], + "result": { + "name": "StartSendTransactionFlowResult", + "summary": "The result of starting the send transaction flow", + "schema": { + "type": "object", + "description": "Interface ID for the created send form" + } + }, + "examples": [ + { + "name": "startSendTransactionFlowExample", + "description": "Example of starting a send transaction flow for SOL", + "params": [ + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + }, + { + "name": "account", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "assetId", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" + } + ], + "result": { + "name": "startSendTransactionFlowResult", + "value": { + "interfaceId": "sendTransactionForm" + } + } + } + ] + }, + { + "name": "getFeeForTransaction", + "summary": "Get the fee for a transaction", + "description": "Calculates and returns the fee for a given transaction in lamports. This method helps users understand the cost of their transaction before execution. **Note: This method is deprecated and will be removed in the next major version.**", + "paramStructure": "by-name", + "params": [ + { + "name": "transaction", + "summary": "The base64-encoded transaction to calculate fees for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "summary": "The Solana network scope", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + } + ], + "result": { + "name": "GetFeeForTransactionResult", + "summary": "The fee for the transaction in lamports", + "schema": { + "$ref": "#/components/schemas/GetFeeForTransactionResult" + } + }, + "examples": [ + { + "name": "getFeeForTransactionExample", + "description": "Example of getting the fee for a transaction", + "params": [ + { + "name": "transaction", + "value": "gAEAAgSZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdN2zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAHWszLmyDo8VIk2P/sVmDUn34YE2+73fS1kNLCNojDEqAwMABQIsAQAAAwAJA+gDAAAAAAAAAgIAAQwCAAAAQEIPAAAAAAAA" + }, + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + } + ], + "result": { + "name": "getFeeForTransactionResult", + "value": { + "value": "15000" + } + } + } + ] + }, + { + "name": "getGenesisHash", + "summary": "Get the genesis hash for a Solana network", + "description": "Retrieves the genesis hash for the specified Solana network. This method provides network-specific information.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "GetGenesisHashResult", + "summary": "The genesis hash for the network", + "schema": { + "type": "string", + "description": "The genesis hash as a string" + } + }, + "examples": [ + { + "name": "getGenesisHashExample", + "description": "Example of getting the genesis hash for mainnet", + "params": [], + "result": { + "name": "genesisHash", + "value": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + } + } + ] + }, + { + "name": "getLatestBlockhash", + "summary": "Get the latest blockhash for a Solana network", + "description": "Retrieves the latest blockhash for the specified Solana network. This method provides current network state information.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "GetLatestBlockhashResult", + "summary": "The latest blockhash for the network", + "schema": { + "type": "string", + "description": "The latest blockhash as a string" + } + }, + "examples": [ + { + "name": "getLatestBlockhashExample", + "description": "Example of getting the latest blockhash", + "params": [], + "result": { + "name": "latestBlockhash", + "value": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + } + } + ] + }, + { + "name": "getMinimumBalanceForRentExemption", + "summary": "Get the minimum balance for rent exemption", + "description": "Retrieves the minimum balance required for rent exemption on the specified Solana network.", + "paramStructure": "by-name", + "params": [ + { + "name": "dataLength", + "summary": "The length of data in bytes", + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "commitment", + "summary": "Optional commitment level", + "required": false, + "schema": { + "$ref": "#/components/schemas/TransactionCommitment" + } + } + ], + "result": { + "name": "GetMinimumBalanceForRentExemptionResult", + "summary": "The minimum balance for rent exemption in lamports", + "schema": { + "type": "string", + "description": "The minimum balance as a string" + } + }, + "examples": [ + { + "name": "getMinimumBalanceForRentExemptionExample", + "description": "Example of getting minimum balance for rent exemption", + "params": [ + { + "name": "dataLength", + "value": 1000 + }, + { + "name": "commitment", + "value": "confirmed" + } + ], + "result": { + "name": "minimumBalance", + "value": "890880" + } + } + ] + }, + { + "name": "signRewardsMessage", + "summary": "Sign a rewards message without confirmation", + "description": "Signs a rewards message directly without prompting user confirmation. This method validates that the message follows the specific rewards format, which must contain a valid Solana address and a Unix timestamp within 1 minute of the current time: `'rewards,{address},{timestamp}'`", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "summary": "The UUID of the account to use for signing the message", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "message", + "summary": "The base64-encoded rewards message to sign", + "required": true, + "schema": { + "$ref": "#/components/schemas/Base64String" + } + } + ], + "result": { + "name": "SignRewardsMessageResult", + "summary": "The result of signing a rewards message", + "schema": { + "type": "object", + "properties": { + "signature": { + "$ref": "#/components/schemas/Base58String", + "description": "The base58-encoded signature of the message" + }, + "signedMessage": { + "$ref": "#/components/schemas/Base64String", + "description": "The original base64-encoded message that was signed" + }, + "signatureType": { + "type": "string", + "enum": ["ed25519"], + "description": "The signature algorithm used" + } + }, + "required": ["signature", "signedMessage", "signatureType"], + "additionalProperties": false + } + }, + "examples": [ + { + "name": "signRewardsMessageExample", + "description": "Example of signing a rewards message for a Solana address", + "params": [ + { + "name": "accountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "message", + "value": "cmV3YXJkcyxCTHczUndlSm1mYlRhcEpSZ25QUnZkOTYyWURqRllBblZHZDFwNWhtWjV0UCwxNzM2NjYwMDAw" + } + ], + "result": { + "name": "signRewardsMessageResult", + "value": { + "signature": "61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu", + "signedMessage": "cmV3YXJkcyxCTHczUndlSm1mYlRhcEpSZ25QUnZkOTYyWURqRllBblZHZDFwNWhtWjV0UCwxNzM2NjYwMDAw", + "signatureType": "ed25519" + } + } + } + ] + }, + { + "name": "signCardMessage", + "summary": "Sign a card-related SIWS message without confirmation", + "description": "Signs a card-related message in SIWS (Sign-In with Solana) format directly without prompting user confirmation. This method validates that the message follows the specific format and that the address in the message matches the signing account. Message format: `'{domain} wants you to sign in with your Solana account: {address} {statement} URI: {uri} Version: {version} Chain ID: {chainId} Nonce: {nonce} Issued At: {issuedAt} Expiration Time: {expirationTime}'`", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "summary": "The UUID of the account to use for signing the message", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "message", + "summary": "The base64-encoded SIWS-style message to sign. Must contain a valid Solana address that matches the signing account.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Base64String" + } + } + ], + "result": { + "name": "SignCardMessageResult", + "summary": "The result of signing a card message", + "schema": { + "type": "object", + "properties": { + "signature": { + "$ref": "#/components/schemas/Base58String", + "description": "The base58-encoded signature of the message" + }, + "signedMessage": { + "$ref": "#/components/schemas/Base64String", + "description": "The original base64-encoded message that was signed" + }, + "signatureType": { + "type": "string", + "enum": ["ed25519"], + "description": "The signature algorithm used" + } + }, + "required": ["signature", "signedMessage", "signatureType"], + "additionalProperties": false + } + }, + "examples": [ + { + "name": "signCardMessageExample", + "description": "Example of signing a SIWS-style card message for a Solana address", + "params": [ + { + "name": "accountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "message", + "value": "YXBwcm92ZS5jYXJkLm1ldGFtYXNrLmlvIHdhbnRzIHlvdSB0byBzaWduIGluIHdpdGggeW91ciBTb2xhbmEgYWNjb3VudDogQkx3M1J3ZUptZmJUYXBKUmduUFJ2ZDk2MllEakZZQW5WR2QxcDVobVo1dFAgUHJvdmUgYWRkcmVzcyBvd25lcnNoaXAgVVJJOiBodHRwczovL2FwcHJvdmUuY2FyZC5tZXRhbWFzay5pbyBWZXJzaW9uOiAxIENoYWluIElEOiAxIE5vbmNlOiBhOTBUTEZNYkRGR0RXVVRmcyBJc3N1ZWQgQXQ6IDIwMjUtMTItMDJUMTQ6MjU6NDkuNTg5WiBFeHBpcmF0aW9uIFRpbWU6IDIwMjUtMTItMDJUMTQ6MzU6NDkuNTg5Wg==" + } + ], + "result": { + "name": "signCardMessageResult", + "value": { + "signature": "61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu", + "signedMessage": "YXBwcm92ZS5jYXJkLm1ldGFtYXNrLmlvIHdhbnRzIHlvdSB0byBzaWduIGluIHdpdGggeW91ciBTb2xhbmEgYWNjb3VudDogQkx3M1J3ZUptZmJUYXBKUmduUFJ2ZDk2MllEakZZQW5WR2QxcDVobVo1dFAgUHJvdmUgYWRkcmVzcyBvd25lcnNoaXAgVVJJOiBodHRwczovL2FwcHJvdmUuY2FyZC5tZXRhbWFzay5pbyBWZXJzaW9uOiAxIENoYWluIElEOiAxIE5vbmNlOiBhOTBUTEZNYkRGR0RXVVRmcyBJc3N1ZWQgQXQ6IDIwMjUtMTItMDJUMTQ6MjU6NDkuNTg5WiBFeHBpcmF0aW9uIFRpbWU6IDIwMjUtMTItMDJUMTQ6MzU6NDkuNTg5Wg==", + "signatureType": "ed25519" + } + } + } + ] + }, + { + "name": "approveCardAmount", + "summary": "Approve a delegate to spend tokens on behalf of the user", + "description": "Creates and signs an SPL token approval transaction that authorizes a delegate address to spend a specified amount of tokens from the user's associated token account. This is used by the Card team to enable card-related token spending.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "summary": "The UUID of the account to use for signing the approval transaction", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, + { + "name": "amount", + "summary": "The amount to approve as a positive number string (in token units, e.g., '100.50' for 100.50 USDC)", + "required": true, + "schema": { + "$ref": "#/components/schemas/PositiveNumberString" + } + }, + { + "name": "mint", + "summary": "The token mint address (e.g., USDC mint address)", + "required": true, + "schema": { + "$ref": "#/components/schemas/SolanaAddress" + } + }, + { + "name": "delegate", + "summary": "The Solana address that is authorized to spend the approved amount", + "required": true, + "schema": { + "$ref": "#/components/schemas/SolanaAddress" + } + }, + { + "name": "scope", + "summary": "The Solana network to send the transaction to", + "required": true, + "schema": { + "$ref": "#/components/schemas/Network" + } + } + ], + "result": { + "name": "ApproveCardAmountResult", + "summary": "The result of approving a card amount", + "schema": { + "type": "object", + "properties": { + "signature": { + "$ref": "#/components/schemas/Base58String", + "description": "The transaction signature returned after successful execution on Solana" + } + }, + "required": ["signature"], + "additionalProperties": false + } + }, + "examples": [ + { + "name": "approveCardAmountExample", + "description": "Example of approving a delegate to spend 100.50 USDC", + "params": [ + { + "name": "accountId", + "value": "c747acb9-1b2b-4352-b9da-3d658fcc3cc7" + }, + { + "name": "amount", + "value": "100.50" + }, + { + "name": "mint", + "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + { + "name": "delegate", + "value": "4jepDb74FCMr1wgoSA34FeJ2mkvEsJBRZQQRumqp9EL3" + }, + { + "name": "scope", + "value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + } + ], + "result": { + "name": "approveCardAmountResult", + "value": { + "signature": "61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu" + } + } + } + ] + } + ], + "components": { + "schemas": { + "Base64String": { + "type": "string", + "description": "A base64-encoded string", + "pattern": "^[A-Za-z0-9+/]*={0,2}$" + }, + "Base58String": { + "type": "string", + "description": "A base58-encoded string", + "pattern": "^[1-9A-HJ-NP-Za-km-z]+$" + }, + "Uuid": { + "type": "string", + "description": "A UUID string in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "PositiveNumberString": { + "type": "string", + "description": "A positive number as a string, excluding leading zeros", + "pattern": "^(?!0\\d)(\\d+(\\.\\d+)?)$" + }, + "SolanaAddress": { + "type": "string", + "description": "A valid Solana address", + "pattern": "^[1-9A-HJ-NP-Za-km-z]{32,44}$" + }, + "CaipAssetType": { + "type": "string", + "description": "A CAIP-19 asset type identifier", + "pattern": "^[a-z]+:[0-9a-f]+(/[a-z]+:[0-9a-f]+)*$" + }, + "WalletAccount": { + "type": "object", + "description": "A wallet account containing the address", + "properties": { + "address": { + "$ref": "#/components/schemas/SolanaAddress", + "description": "The base58-encoded public key address of the account" + } + }, + "required": ["address"], + "additionalProperties": false + }, + "Network": { + "type": "string", + "description": "The Solana network identifier using CAIP-2 format", + "enum": [ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" + ] + }, + "TransactionCommitment": { + "type": "string", + "description": "The commitment level for transaction confirmation", + "enum": ["processed", "confirmed", "finalized"], + "examples": ["processed", "confirmed", "finalized"] + }, + "TransactionOptions": { + "type": "object", + "description": "Optional parameters for transaction processing", + "properties": { + "preflightCommitment": { + "$ref": "#/components/schemas/TransactionCommitment", + "description": "Preflight commitment level" + }, + "minContextSlot": { + "type": "integer", + "description": "The minimum slot that the request can be evaluated at", + "minimum": 0 + }, + "commitment": { + "$ref": "#/components/schemas/TransactionCommitment", + "description": "Desired commitment level. If provided, confirm the transaction after sending" + }, + "skipPreflight": { + "type": "boolean", + "description": "Disable transaction verification at the RPC" + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of times for the RPC node to retry sending the transaction to the leader", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "SignAndSendTransactionResult": { + "type": "object", + "description": "Result of signing and sending a transaction", + "properties": { + "signature": { + "$ref": "#/components/schemas/Base58String", + "description": "The transaction signature returned after successful execution on Solana" + } + }, + "required": ["signature"], + "additionalProperties": false + }, + "ValidationResponse": { + "type": "object", + "description": "Response from validation methods", + "properties": { + "valid": { + "type": "boolean", + "description": "Whether the input is valid" + }, + "errors": { + "type": "array", + "description": "Array of validation errors", + "items": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/SendErrorCode" + } + }, + "required": ["code"], + "additionalProperties": false + } + } + }, + "required": ["valid", "errors"], + "additionalProperties": false + }, + "SendErrorCode": { + "type": "string", + "description": "Error codes for send validation", + "enum": [ + "Required", + "Invalid", + "InsufficientBalanceToCoverFee", + "InsufficientBalance" + ] + }, + "GetFeeForTransactionResult": { + "type": "object", + "description": "Result of getting transaction fee", + "properties": { + "value": { + "type": "string", + "description": "The fee amount in lamports as a string" + } + }, + "required": ["value"], + "additionalProperties": false + } + } + } +} diff --git a/merged-packages/solana-wallet-snap/package.json b/merged-packages/solana-wallet-snap/package.json new file mode 100644 index 00000000..dfece6ce --- /dev/null +++ b/merged-packages/solana-wallet-snap/package.json @@ -0,0 +1,90 @@ +{ + "name": "@metamask/solana-wallet-snap", + "version": "3.0.0", + "description": "A Solana wallet Snap.", + "keywords": [ + "MetaMask", + "Solana" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-solana-wallet.git" + }, + "files": [ + "dist/", + "snap.manifest.json", + "images/", + "locales/" + ], + "main": "./dist/bundle.js", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "allow-scripts": "yarn workspace root allow-scripts", + "build": "mm-snap build && yarn locale:build && yarn build-preinstalled-snap", + "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", + "build:clean": "yarn clean && yarn build", + "clean": "rimraf dist", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn lint:deps && yarn lint:types", + "lint:deps": "depcheck", + "lint:eslint": "yarn run -T eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc", + "lint:misc": "yarn run -T oxfmt --ignore-path ../../.gitignore", + "lint:types": "tsc --noEmit", + "locale:build": "node ./scripts/populate-locale.js", + "locale:build:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-locale.js\"", + "prepublishOnly": "mm-snap manifest", + "serve": "mm-snap serve", + "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn locale:build:watch\"", + "test": "jest --passWithNoTests --runInBand", + "test:core": "jest src/core --passWithNoTests", + "test:core:watch": "yarn test:core --watch", + "test:features": "jest src/features --passWithNoTests --runInBand", + "test:features:watch": "yarn test:features --watch", + "test:watch": "yarn test --watch" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@metamask/auto-changelog": "4.0.0", + "@metamask/key-tree": "9.1.2", + "@metamask/keyring-api": "^23.5.0", + "@metamask/keyring-snap-sdk": "^9.2.0", + "@metamask/snaps-cli": "^8.4.1", + "@metamask/snaps-jest": "^10.2.0", + "@metamask/snaps-sdk": "^11.1.1", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", + "@noble/ed25519": "2.1.0", + "@solana-name-service/sns-sdk-kit": "0.9.0-beta", + "@solana-program/compute-budget": "0.7.0", + "@solana-program/system": "0.7.0", + "@solana-program/token": "0.5.1", + "@solana-program/token-2022": "^0.5.0", + "@solana/kit": "2.1.0", + "@testing-library/jest-dom": "^6.6.2", + "@types/express": "^5.0.0", + "@types/lodash": "^4.17.15", + "@types/react": "18.2.4", + "@types/react-dom": "18.2.4", + "async-mutex": "^0.5.0", + "bignumber.js": "^9.1.2", + "bs58": "^6.0.0", + "buffer": "^6.0.3", + "concurrently": "^9.1.0", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jest": "30.0.3", + "jest-transform-stub": "^2.0.0", + "lodash": "^4.17.21", + "oxfmt": "^0.45.0", + "rimraf": "^6.0.1", + "ts-jest": "^29.4.1", + "typescript": "~5.8.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/merged-packages/solana-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/solana-wallet-snap/scripts/build-preinstalled-snap.js new file mode 100644 index 00000000..e5fda20e --- /dev/null +++ b/merged-packages/solana-wallet-snap/scripts/build-preinstalled-snap.js @@ -0,0 +1,77 @@ +// @ts-check +/* eslint-disable */ + +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 enLocalePath = require.resolve('../locales/en.json'); + +// File Contents +const bundle = readFileContents(bundlePath); +const icon = readFileContents(iconPath); +const manifest = readFileContents(manifestPath); +const enLocale = readFileContents(enLocalePath); + +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: enLocale, + }, + ], + 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/solana-wallet-snap/scripts/populate-locale.js b/merged-packages/solana-wallet-snap/scripts/populate-locale.js new file mode 100644 index 00000000..2835d8c7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/scripts/populate-locale.js @@ -0,0 +1,41 @@ +/** + * Populates the English locale file with messages from a JSON source. + * + * This script reads messages from a JSON file, transforms them into a specific format, + * and writes them to an English locale file (`en.json`). + * + * @example + * node populate-locale.js + * @throws Will throw an error if writing to the locale file fails. + */ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const messages = require('../messages.json'); + +console.log('🌍 Populating EN locale'); + +const englishLocale = Object.assign( + { locale: 'en' }, + Object.entries(messages).reduce( + (list, [key, value]) => { + list.messages[key] = { message: value }; + return list; + }, + { messages: {} }, + ), +); + +// Write en locale file +try { + // Trailing newline keeps oxfmt / lint:misc happy and avoids shasum drift + // after `locale:build` (locales are included in the snap checksum). + writeFileSync( + join(__dirname, '../locales/en.json'), + `${JSON.stringify(englishLocale, null, 2)}\n`, + ); + console.log('🌍 EN locale populated ✅'); +} catch (error) { + console.error('❌ Error writing en locale file', error); + throw error; +} diff --git a/merged-packages/solana-wallet-snap/scripts/update-manifest-local.js b/merged-packages/solana-wallet-snap/scripts/update-manifest-local.js new file mode 100644 index 00000000..f980bc2a --- /dev/null +++ b/merged-packages/solana-wallet-snap/scripts/update-manifest-local.js @@ -0,0 +1,55 @@ +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 && + manifest.initialPermissions['endowment:keyring'] && + manifest.initialPermissions['endowment:keyring'].allowedOrigins + ) { + if ( + !manifest.initialPermissions['endowment:keyring'].allowedOrigins.includes( + 'http://localhost:3000', + ) + ) { + manifest.initialPermissions['endowment:keyring'].allowedOrigins.push( + 'http://localhost:3000', + ); + } + } + + console.log('Added localhost entries to snap.manifest.json'); +} else if (environment === 'production') { + // Remove entries for production + if ( + manifest.initialConnections && + manifest.initialConnections['http://localhost:3000'] + ) { + delete manifest.initialConnections['http://localhost:3000']; + } + + if ( + manifest.initialPermissions && + manifest.initialPermissions['endowment:keyring'] && + manifest.initialPermissions['endowment:keyring'].allowedOrigins + ) { + manifest.initialPermissions['endowment:keyring'].allowedOrigins = + manifest.initialPermissions['endowment:keyring'].allowedOrigins.filter( + (origin) => origin !== 'http://localhost:3000', + ); + } + + console.log('Removed localhost entries from snap.manifest.json'); +} + +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + +console.log('Updated snap.manifest.json with localhost entries'); diff --git a/merged-packages/solana-wallet-snap/snap.config.ts b/merged-packages/solana-wallet-snap/snap.config.ts new file mode 100644 index 00000000..4501faca --- /dev/null +++ b/merged-packages/solana-wallet-snap/snap.config.ts @@ -0,0 +1,39 @@ +/* eslint-disable n/no-process-env */ +import type { SnapConfig } from '@metamask/snaps-cli'; +import * as dotenv from 'dotenv'; +import { resolve } from 'path'; + +dotenv.config(); + +const environment = { + ENVIRONMENT: process.env.ENVIRONMENT ?? '', + RPC_URL_MAINNET_LIST: process.env.RPC_URL_MAINNET_LIST ?? '', + RPC_URL_DEVNET_LIST: process.env.RPC_URL_DEVNET_LIST ?? '', + RPC_URL_TESTNET_LIST: process.env.RPC_URL_TESTNET_LIST ?? '', + RPC_URL_LOCALNET_LIST: process.env.RPC_URL_LOCALNET_LIST ?? '', + RPC_WEB_SOCKET_URL_MAINNET: process.env.RPC_WEB_SOCKET_URL_MAINNET ?? '', + RPC_WEB_SOCKET_URL_DEVNET: process.env.RPC_WEB_SOCKET_URL_DEVNET ?? '', + RPC_WEB_SOCKET_URL_TESTNET: process.env.RPC_WEB_SOCKET_URL_TESTNET ?? '', + RPC_WEB_SOCKET_URL_LOCALNET: process.env.RPC_WEB_SOCKET_URL_LOCALNET ?? '', + EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL ?? '', + PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', + TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', + STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', + SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL ?? '', + NFT_API_BASE_URL: process.env.NFT_API_BASE_URL ?? '', + LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', +}; + +const config: SnapConfig = { + input: resolve(__dirname, 'src/index.ts'), + server: { + port: 8080, + }, + environment, + polyfills: { + buffer: true, + crypto: true, + }, +}; + +export default config; diff --git a/merged-packages/solana-wallet-snap/snap.manifest.json b/merged-packages/solana-wallet-snap/snap.manifest.json new file mode 100644 index 00000000..8ab617d6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/snap.manifest.json @@ -0,0 +1,95 @@ +{ + "version": "3.0.0", + "description": "Manage Solana using MetaMask", + "proposedName": "Solana", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-solana-wallet.git" + }, + "source": { + "shasum": "VN4aDapRDvXKljuAXN4JalZVaHZZiniM8YUaCf3B7hQ=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "iconPath": "images/icon.svg", + "packageName": "@metamask/solana-wallet-snap", + "registry": "https://registry.npmjs.org/" + } + }, + "locales": ["locales/en.json"] + }, + "initialConnections": { + "https://portfolio.metamask.io": {} + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": false + }, + "endowment:keyring": { + "allowedOrigins": ["https://portfolio.metamask.io"], + "capabilities": { + "scopes": ["solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"], + "privateKey": { + "exportFormats": [ + { + "encoding": "base58" + } + ] + }, + "bip44": { + "deriveIndex": true, + "deriveIndexRange": true, + "discover": true + } + } + }, + "snap_getBip32Entropy": [ + { + "path": ["m", "44'", "501'"], + "curve": "ed25519" + } + ], + "endowment:lifecycle-hooks": {}, + "endowment:network-access": {}, + "endowment:cronjob": { + "jobs": [] + }, + "endowment:protocol": { + "scopes": { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "methods": [ + "getGenesisHash", + "getLatestBlockhash", + "getMinimumBalanceForRentExemption" + ] + }, + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + "methods": [ + "getGenesisHash", + "getLatestBlockhash", + "getMinimumBalanceForRentExemption" + ] + } + } + }, + "endowment:assets": { + "scopes": [ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ] + }, + "endowment:name-lookup": { + "chains": [ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ] + }, + "snap_manageAccounts": {}, + "snap_manageState": {}, + "snap_dialog": {}, + "snap_getPreferences": {} + }, + "platformVersion": "11.1.1", + "manifestVersion": "0.1" +} diff --git a/merged-packages/solana-wallet-snap/src/core/caching/ICache.ts b/merged-packages/solana-wallet-snap/src/core/caching/ICache.ts new file mode 100644 index 00000000..8a7a71f4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/ICache.ts @@ -0,0 +1,97 @@ +/** + * Interface for a generic cache implementation. + * + * @template TValue - The type of values stored in the cache + */ +export type ICache = { + /** + * Retrieves a value from the cache by key. + * + * @param key - The key to retrieve + * @returns The value if found, undefined if not found + */ + get(key: string): Promise; + + /** + * Stores a value in the cache with an optional TTL. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param key - The key to store the value under + * @param value - The value to store + * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + set(key: string, value: TValue, ttlMilliseconds?: number): Promise; + + /** + * Removes a value from the cache. + * + * @param key - The key to remove + * @returns true if the key was found and removed, false otherwise + */ + delete(key: string): Promise; + + /** + * Removes all values from the cache. + */ + clear(): Promise; + + /** + * Checks if a key exists in the cache. + * + * @param key - The key to check + * @returns true if the key exists, false otherwise + */ + has(key: string): Promise; + + /** + * Returns all keys currently in the cache. + * + * @returns Array of keys + */ + keys(): Promise; + + /** + * Returns the number of items in the cache. + * + * @returns The number of items + */ + size(): Promise; + + /** + * Retrieves a value from the cache without affecting its TTL or last accessed time. + * + * @param key - The key to peek at + * @returns The value if found, undefined if not found + */ + peek(key: string): Promise; + + /** + * Retrieves multiple values from the cache in a single operation. + * + * @param keys - Array of keys to retrieve + * @returns Object mapping keys to their values (or undefined if not found) + */ + mget(keys: string[]): Promise>; + + /** + * Stores multiple values in the cache in a single operation. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + mset( + entries: { key: string; value: TValue; ttlMilliseconds?: number }[], + ): Promise; + + /** + * Removes multiple values from the cache. + * + * @param keys - Array of keys to remove + * @returns An object mapping each key to a boolean indicating whether it was found and removed + */ + mdelete(keys: string[]): Promise>; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts b/merged-packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts new file mode 100644 index 00000000..f5e733c0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts @@ -0,0 +1,183 @@ +import { assert } from '@metamask/utils'; + +import type { Serializable } from '../serialization/types'; +import { createPrefixedLogger } from '../utils/logger'; +import type { ILogger } from '../utils/logger'; +import type { ICache } from './ICache'; +import type { CacheEntry } from './types'; + +/** + * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. + * + * WARNINGS: + * - This cache is not persistent and will be lost when the process is restarted. + */ +export class InMemoryCache implements ICache { + readonly #cache: Map = new Map(); + + public readonly logger: ILogger; + + constructor(logger: ILogger) { + this.logger = createPrefixedLogger(logger, '[💾 InMemoryCache]'); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + #isExpired(cacheEntry: CacheEntry): boolean { + return cacheEntry.expiresAt < Date.now(); + } + + async #cleanupExpiredEntries(): Promise { + const expiredKeys: string[] = []; + for (const [key, entry] of this.#cache.entries()) { + if (this.#isExpired(entry)) { + expiredKeys.push(key); + } + } + await this.mdelete(expiredKeys); + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + this.#cache.clear(); + } + + async has(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return false; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return false; + } + + return true; + } + + async keys(): Promise { + await this.#cleanupExpiredEntries(); + return Array.from(this.#cache.keys()); + } + + async size(): Promise { + await this.#cleanupExpiredEntries(); + return this.#cache.size; + } + + async peek(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return undefined; + } + + return cacheEntry.value; + } + + async mget( + keys: string[], + ): Promise> { + await this.#cleanupExpiredEntries(); + + const result: Record = {}; + + for (const key of keys) { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + this.logger.info(`❌ Cache miss for key "${key}"`); + result[key] = undefined; + continue; + } + + this.logger.info(`🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + }); + } + + async mdelete(keys: string[]): Promise> { + return Object.fromEntries( + keys.map((key) => [key, this.#cache.delete(key)]), + ); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/caching/StateCache.test.ts b/merged-packages/solana-wallet-snap/src/core/caching/StateCache.test.ts new file mode 100644 index 00000000..8e85b1d6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/StateCache.test.ts @@ -0,0 +1,781 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import { InMemoryState } from '../services/state/InMemoryState'; +import { StateCache } from './StateCache'; + +describe('StateCache', () => { + describe('constructor', () => { + it('uses the default prefix if not specified', () => { + const cache = new StateCache(new InMemoryState({})); + + expect(cache.prefix).toBe('__cache__default'); + }); + + it('uses the specified prefix if provided', () => { + const cache = new StateCache( + new InMemoryState({}), + undefined, + '__cache__my-prefix', + ); + + expect(cache.prefix).toBe('__cache__my-prefix'); + }); + }); + + describe('get', () => { + it('returns undefined if the cache is not initialized', async () => { + const stateWithNoCache = new InMemoryState({ + name: 'John', // State has some data that is not related to the cache + // __cache__default: {} // State has not been initialized with cached data + }); + const cache = new StateCache(stateWithNoCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined if the cache is initialized but the key is not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someOtherKey'); + + expect(value).toBeUndefined(); + }); + + it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + }); + + it('returns undefined if the cache is initialized and the key is present but expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.get('someKey'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('set', () => { + it('initializes the cache if it is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with no expiration if no ttl is provided', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('overwrites the cache entry if it is present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someOtherValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with the provided ttl', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 1000); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067201000, // January 1, 2024 + 1 second + }, + }, + }); + }); + + it('supports a ttl of 0', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1704067200000) // January 1, 2024 + .mockReturnValueOnce(1704067200001); // January 1, 2024 + 1 millisecond + + await cache.set('someKey', 'someValue', 0); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) + }, + }, + }); + + const value = await cache.get('someKey'); // Should expire immediately + expect(value).toBeUndefined(); + }); + + it('throws an error if the ttl is not a number', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('deletes the cache entry and returns true if the entry was present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.delete('someKey'); + expect(result).toBe(true); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('leaves the cache unchanged and returns false if the entry was not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.delete('someOtherKey'); // Try to + const someKeyValue = await cache.get('someKey'); + const someOtherKeyValue = await cache.get('someOtherKey'); + + expect(result).toBe(false); + expect(someKeyValue).toBe('someValue'); + expect(someOtherKeyValue).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('empties the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + createdAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('has', () => { + it('returns true if the key is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someKey'); + + expect(result).toBe(true); + }); + + it('returns false if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someOtherKey'); + expect(result).toBe(false); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someKey'); + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual(['someKey', 'someOtherKey']); + }); + + it('returns an empty array if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(2); + }); + + it('returns 0 if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value of an unexpired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns the value of an expired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns undefined if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someOtherKey'); + + expect(result).toBeUndefined(); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + expect(result).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('returns the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('returns undefined for keys that are not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: 'someValue', + someOtherKey: undefined, + }); + }); + + it('returns undefined for keys that are expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + }); + + it('returns an empty object if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({}); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mget(['someKey']); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + }); + + describe('mset', () => { + it('sets the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'someOtherKey', value: 'someOtherValue' }, + ]); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('does not store undefined values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'undefinedKey', value: undefined }, + ]); + + const result = await cache.mget(['someKey', 'undefinedKey']); + + expect(result).toEqual({ + someKey: 'someValue', + undefinedKey: undefined, + }); + + // Verify the undefined value was not stored in the cache + const stateValue = await stateWithCache.get(); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('stores null values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: null }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: null, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: 'someValue' }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + }); + }); + + it('throws an error if the ttl is invalid', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await expect( + cache.mset([ + { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + + it('does not affect other keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey0: { + value: 'someValue0', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someKey1: { + value: 'someValue1', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey0', value: 'someValue0Overwritten' }, + { key: 'someKey2', value: 'someValue2' }, + ]); + + const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); + + expect(result).toStrictEqual({ + someKey0: 'someValue0Overwritten', + someKey1: 'someValue1', + someKey2: 'someValue2', + }); + }); + + it('no-ops if no entries are provided', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + const updateSpy = jest.spyOn(stateWithCache, 'update'); + + await cache.mset([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('defers to set if there is only one entry', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + const setSpy = jest.spyOn(cache, 'set'); + + const singleEntry = { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 1000, + }; + await cache.mset([singleEntry]); + + expect(setSpy).toHaveBeenCalledWith( + singleEntry.key, + singleEntry.value, + singleEntry.ttlMilliseconds, + ); + }); + }); + + describe('mdelete', () => { + it('deletes the keys from the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mdelete(['someKey', 'someOtherKey']); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: undefined, + someOtherKey: undefined, + }); + }); + + it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: true, + someOtherKey: false, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: false, + someOtherKey: false, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/caching/StateCache.ts b/merged-packages/solana-wallet-snap/src/core/caching/StateCache.ts new file mode 100644 index 00000000..dbfbec1f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/StateCache.ts @@ -0,0 +1,255 @@ +import { assert } from '@metamask/utils'; + +import type { Serializable } from '../serialization/types'; +import type { IStateManager } from '../services/state/IStateManager'; +import type { ILogger } from '../utils/logger'; +import type { ICache } from './ICache'; +import type { CacheEntry } from './types'; + +/** + * The whole cache store. + */ +export type CacheStore = Record | undefined; + +/** + * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. + */ +export type CachePrefix = `__cache__${string}`; + +/** + * Describes the shape of the whole state inside which the cache is stored. + */ +export type StateValue = { + [x: string]: Serializable; +} & { + [K in CachePrefix]?: CacheStore; +}; + +/** + * A cache that wraps any implementation of the `IStateManager` interface to store the cache. + * + * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes. + * + * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. + * This is useful if you want to have multiple independent caches in the same state. + * + * ``` + * { + * ..., // other state values + * __cache__default: { + * key1: value1, + * key2: value2, + * }, + * __cache__my-prefix: { + * key3: value3, + * key4: value4, + * }, + * } + * ``` + * + * @example + * ```ts + * const state = new State({}); // Here we use the real snap's state + * const cache = new StateCache(state, '__cache__my-prefix'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // no __cache__my-prefix yet + * // } + * + * await cache.set('key1', 'value1'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // __cache__my-prefix: { + * // key1: value1, + * // }, + * // } + * ``` + */ +export class StateCache implements ICache { + readonly #state: IStateManager; + + public readonly prefix: CachePrefix; + + public readonly logger: ILogger; + + constructor( + state: IStateManager, + logger: ILogger = console, + prefix: CachePrefix = '__cache__default', + ) { + this.#state = state; + this.logger = logger; + this.prefix = prefix; + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + await this.#state.setKey(`${this.prefix}.${key}`, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + await this.#state.setKey(this.prefix, {}); + } + + async has(key: string): Promise { + const result = await this.get(key); + return result !== undefined; + } + + async keys(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}); + } + + async size(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}).length; + } + + async peek(key: string): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + const cacheEntry = cacheStore?.[key]; + + return cacheEntry?.value; + } + + async mget( + keys: string[], + ): Promise> { + const cacheStore = await this.#state.getKey(this.prefix); + + const keysAndValues = Object.entries(cacheStore ?? {}).filter(([key]) => + keys.includes(key), + ); + + const expiredKeys = keysAndValues.filter( + ([_, cacheEntry]) => cacheEntry && cacheEntry.expiresAt < Date.now(), + ); + + await this.mdelete(expiredKeys.map(([key]) => key)); + + return keysAndValues.reduce>( + (acc, [key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + return acc; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + acc[key] = undefined; + } else { + this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + acc[key] = cacheEntry.value; + } + + return acc; + }, + {}, + ); + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + cacheStore[key] = { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }; + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + } + + async mdelete(keys: string[]): Promise> { + const result: Record = {}; + + // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + keys.forEach((key) => { + if (cacheStore[key] === undefined) { + result[key] = false; + } else { + delete cacheStore[key]; + result[key] = true; + } + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + + return result; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/caching/types.ts b/merged-packages/solana-wallet-snap/src/core/caching/types.ts new file mode 100644 index 00000000..a06842d0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/types.ts @@ -0,0 +1,11 @@ +import type { Serializable } from '../serialization/types'; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/caching/useCache.test.ts b/merged-packages/solana-wallet-snap/src/core/caching/useCache.test.ts new file mode 100644 index 00000000..a5bc7023 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/useCache.test.ts @@ -0,0 +1,246 @@ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './ICache'; +import { useCache } from './useCache'; +import type { CacheOptions } from './useCache'; + +describe('useCache', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheOptions; + + // Original test functions + let testFunction: () => Promise; + let testFunctionWithArgs: (arg1: string, arg2: number) => Promise; + let testFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: ( + arg1: string, + arg2: number, + ) => Promise; + let cachedTestFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + beforeEach(() => { + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue('test'); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + ttlMilliseconds: 1000, + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async () => actualExecutionSpy(); + testFunctionWithArgs = async (arg1: string, arg2: number) => + actualExecutionSpy(arg1, arg2); + testFunctionWithComplexArgs = async (obj: { name: string; age: number }) => + actualExecutionSpy(obj); + + // Create cached versions + cachedTestFunction = useCache(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCache(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + + cachedTestFunctionWithComplexArgs = useCache( + testFunctionWithComplexArgs, + cache, + { + ...cacheOptions, + functionName: 'testFunctionWithComplexArgs', + }, + ); + }); + + describe('when the data is not cached', () => { + it('should cache the result of a function', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + }); + + describe('when the data is cached', () => { + it('should return the cached result', async () => { + // Init the cache with some data + jest.spyOn(cache, 'get').mockResolvedValue('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should propagate errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should handle cache get errors gracefully', async () => { + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + + it('should handle cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('different argument types', () => { + it('should handle primitive arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + actualExecutionSpy.mockResolvedValueOnce('test with args'); + + const result = await cachedTestFunctionWithArgs('hello', 42); + + expect(result).toBe('test with args'); + expect(cache.get).toHaveBeenCalledWith('testFunctionWithArgs:"hello":42'); + expect(actualExecutionSpy).toHaveBeenCalledWith('hello', 42); + }); + + it('should handle complex object arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + const testObj = { name: 'John', age: 30 }; + actualExecutionSpy.mockResolvedValueOnce('test with complex args'); + + const result = await cachedTestFunctionWithComplexArgs(testObj); + + expect(result).toBe('test with complex args'); + expect(cache.get).toHaveBeenCalledWith( + 'testFunctionWithComplexArgs:{"name":"John","age":30}', + ); + expect(actualExecutionSpy).toHaveBeenCalledWith(testObj); + }); + }); + + describe('custom generateCacheKey', () => { + it('should use a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.get).toHaveBeenCalledWith('custom-key'); + }); + }); + + describe('anonymous functions', () => { + it('should handle anonymous functions with a default name', async () => { + // Anonymous function with no name + const anonymousFunction = async () => actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCache(anonymousFunction, cache, { + ttlMilliseconds: 1000, + }); + + await cachedAnonymousFunction(); + + expect(cache.get).toHaveBeenCalledWith('anonymousFunction:'); + }); + }); + + describe('function name override', () => { + it('should use the provided function name if given', async () => { + const cachedWithCustomName = useCache(testFunction, cache, { + ttlMilliseconds: 1000, + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.get).toHaveBeenCalledWith('customFunctionName:'); + }); + }); + + describe('falsy but valid cache values', () => { + it('should handle falsy but valid cache values (false, 0, empty string)', async () => { + // Test with false + jest.spyOn(cache, 'get').mockResolvedValue(false); + let result = await cachedTestFunction(); + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with 0 + jest.spyOn(cache, 'get').mockResolvedValue(0); + result = await cachedTestFunction(); + expect(result).toBe(0); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with empty string + jest.spyOn(cache, 'get').mockResolvedValue(''); + result = await cachedTestFunction(); + expect(result).toBe(''); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('should execute the function when cache returns undefined', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/caching/useCache.ts b/merged-packages/solana-wallet-snap/src/core/caching/useCache.ts new file mode 100644 index 00000000..aae86559 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/caching/useCache.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-void */ + +import type { Serializable } from '../serialization/types'; +import logger from '../utils/logger'; +import type { ICache } from './ICache'; + +/** + * Options for configuring the caching behavior of a function. + */ +export type CacheOptions = { + /** + * The time to live for the cache in milliseconds. + */ + ttlMilliseconds: number; + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: any[]) => string; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = (functionName: string, args: any[]) => + `${functionName}:${args.map((arg) => JSON.stringify(arg)).join(':')}`; + +/** + * Wraps an asynchronous function with caching behavior. + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @returns A new asynchronous function with caching behavior. + */ +export const useCache = ( + fn: (...args: TArgs) => Promise, + cache: ICache, + { ttlMilliseconds, functionName, generateCacheKey }: CacheOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + + // Check if the data is cached + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + logger.error(`Cache get error for key "${cacheKey}":`, error); + } + + // Execute the original function + const result = await fn(...args); + + // Cache the result, handle potential errors silently + // We don't await this, allowing it to happen in the background + void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + logger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + return result; + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts new file mode 100644 index 00000000..a22f0fa7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts @@ -0,0 +1,155 @@ +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../services/mocks/logger'; +import { trackError } from '../../utils/errors'; +import { MOCK_NFT_METADATA_RESPONSE_MAPPED } from './mocks/mockNftMetadataResponseMapped'; +import { MOCK_NFT_METADATA_RESPONSE_RAW } from './mocks/mockNftMetadataResponseRaw'; +import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from './mocks/mockNftsListResponseMapped'; +import { MOCK_NFTS_LIST_RESPONSE_RAW } from './mocks/mockNftsListResponseRaw'; +import { NftApiClient } from './NftApiClient'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +const mockFetch = jest.fn(); +let mockCache: ICache; + +describe('NftApiClient', () => { + let client: NftApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCache = new InMemoryCache(mockLogger); + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + nftApi: { + baseUrl: 'https://some-mock-url.com', + cacheTtlsMilliseconds: { + listAddressSolanaNfts: 0, + getNftMetadata: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + client = new NftApiClient( + mockConfigProvider, + mockCache, + mockFetch, + mockLogger, + ); + }); + + describe('constructor', () => { + it('rejects invalid baseUrl', async () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + nftApi: { + baseUrl: 'invalid-url', + cacheTtlsMilliseconds: { + listAddressSolanaNfts: 0, + getNftMetadata: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + expect( + () => + new NftApiClient( + invalidConfigProvider, + mockCache, + mockFetch, + mockLogger, + ), + ).toThrow('Invalid URL format'); + }); + }); + + describe('listAddressSolanaNfts', () => { + it('should correctly map the API response, covering the logic from mapListAddressSolanaNftsResponse.test.ts', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + ...MOCK_NFTS_LIST_RESPONSE_RAW, + cursor: null, + }), + }); + + const nfts = await client.listAddressSolanaNfts('some-address'); + expect(nfts).toStrictEqual(MOCK_NFTS_LIST_RESPONSE_MAPPED.items); + }); + + it('should handle pagination', async () => { + const MOCK_NFTS_LIST_RESPONSE_RAW_PAGE_1 = { + ...MOCK_NFTS_LIST_RESPONSE_RAW, + cursor: 'some-cursor', + }; + const MOCK_NFTS_LIST_RESPONSE_RAW_PAGE_2 = { + ...MOCK_NFTS_LIST_RESPONSE_RAW, + cursor: null, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest + .fn() + .mockResolvedValueOnce(MOCK_NFTS_LIST_RESPONSE_RAW_PAGE_1), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest + .fn() + .mockResolvedValueOnce(MOCK_NFTS_LIST_RESPONSE_RAW_PAGE_2), + }); + const nfts = await client.listAddressSolanaNfts('some-address'); + expect(nfts).toStrictEqual([ + ...MOCK_NFTS_LIST_RESPONSE_MAPPED.items, + ...MOCK_NFTS_LIST_RESPONSE_MAPPED.items, + ]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('getNftMetadata', () => { + it('should fetch and parse solana nft metadata for a given token address', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_NFT_METADATA_RESPONSE_RAW), + }); + const nft = await client.getNftMetadata('some-token-address'); + expect(nft).toStrictEqual(MOCK_NFT_METADATA_RESPONSE_MAPPED); + }); + + it('should return null if the fetch fails', async () => { + const error = new Error('some-error'); + mockFetch.mockRejectedValueOnce(error); + const nft = await client.getNftMetadata('some-token-address'); + expect(nft).toBeNull(); + expect(trackError).toHaveBeenCalledWith(error); + }); + }); + + describe('getNftsMetadata', () => { + it('should fetch and parse solana nft metadata for a given array of token addresses', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_NFT_METADATA_RESPONSE_RAW), + }); + const nfts = await client.getNftsMetadata([ + 'some-token-address-1', + 'some-token-address-2', + ]); + expect(nfts).toStrictEqual([ + MOCK_NFT_METADATA_RESPONSE_MAPPED, + MOCK_NFT_METADATA_RESPONSE_MAPPED, + ]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts new file mode 100644 index 00000000..dd4d819c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { assert } from '@metamask/superstruct'; + +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import { trackError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import { UrlStruct } from '../../validation/structs'; +import type { + Balance, + Nft, + NftApiBalanceType, + NftApiItemType, + PaginatedResponse, +} from './types'; + +export class NftApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #cache: ICache; + + readonly #cacheTtlsMilliseconds: { + listAddressSolanaNfts: number; + getNftMetadata: number; + }; + + constructor( + configProvider: ConfigProvider, + _cache: ICache, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + const { baseUrl, cacheTtlsMilliseconds } = configProvider.get().nftApi; + + assert(baseUrl, UrlStruct); + + this.#fetch = _fetch; + this.#logger = _logger; + this.#baseUrl = baseUrl; + this.#cacheTtlsMilliseconds = cacheTtlsMilliseconds; + + this.#cache = _cache; + } + + #mapListAddressSolanaNftsResponse( + data: PaginatedResponse, + ): PaginatedResponse { + return { + cursor: data.cursor, + error: data.error, + items: data.items.map((item) => ({ + chain: item.chain, + address: item.address, + tokenAddress: item.token_address, + tokenId: item.token_id, + balance: item.balance, + acquiredAt: item.acquired_at, + isSpam: item.isSpam, + nftToken: this.#mapGetNftMetadataResponse(item.nft_token), + })), + }; + } + + async #listAddressSolanaNfts_INTERNAL(address: string) { + let allItems: Balance[] = []; + let currentCursor: string | undefined; + + do { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: `/users/${address}/solana-tokens`, + queryParams: currentCursor ? { cursor: currentCursor } : undefined, + }); + const response = await this.#fetch(url, { + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + version: '1', + }, + }); + const data = await response.json(); + + const mappedData = this.#mapListAddressSolanaNftsResponse(data); + + allItems = [...allItems, ...mappedData.items]; + currentCursor = mappedData.cursor ?? undefined; + } while (currentCursor); + + return allItems; + } + + async listAddressSolanaNfts(address: string) { + return useCache( + this.#listAddressSolanaNfts_INTERNAL.bind(this), + this.#cache, + { + functionName: 'NftApiClient:listAddressSolanaNfts', + ttlMilliseconds: this.#cacheTtlsMilliseconds.listAddressSolanaNfts, + }, + )(address); + } + + #mapGetNftMetadataResponse(data: NftApiItemType): Nft { + return { + address: data.address, + tokenId: data.token_id, + tokenStandard: data.token_standard, + name: data.name, + description: data.description, + metadata: data.metadata, + imageUrl: data.image_url, + mediaUrl: data.media_url, + externalUrl: data.external_url, + attributes: data.attributes.map((attribute) => ({ + key: attribute.key, + value: attribute.value, + })), + tokenAccountAddress: data.token_account_address, + creators: data.creators.map((creator) => ({ + address: creator.address, + share: creator.share, + verified: creator.verified, + })), + collectionName: data.collection_name, + collectionSymbol: data.collection_symbol, + collectionCount: data.collection_count, + collectionImageUrl: data.collection_image_url, + onchainCollectionAddress: data.onchain_collection_address, + floorPrice: data.floor_price + ? { + asset: { + type: data.floor_price.asset.type, + name: data.floor_price.asset.name, + symbol: data.floor_price.asset.symbol, + decimals: data.floor_price.asset.decimals, + tokenId: data.floor_price.asset.token_id, + }, + amount: { + rawAmount: data.floor_price.amount.raw_amount, + amount: data.floor_price.amount.amount, + }, + } + : null, + lastSalePrice: data.last_sale_price + ? { + asset: { + type: data.last_sale_price.asset.type, + name: data.last_sale_price.asset.name, + symbol: data.last_sale_price.asset.symbol, + decimals: data.last_sale_price.asset.decimals, + tokenId: data.last_sale_price.asset.token_id, + }, + amount: { + rawAmount: data.last_sale_price.amount.raw_amount, + amount: data.last_sale_price.amount.amount, + }, + } + : null, + rarity: data.rarity + ? { + ranking: { + source: data.rarity.ranking.source, + value: data.rarity.ranking.value, + }, + metadata: { + howrare: { + rank: data.rarity.metadata.howrare.rank, + }, + moonrank: { + rank: data.rarity.metadata.moonrank.rank, + }, + }, + } + : null, + }; + } + + async #getNftMetadata_INTERNAL(tokenAddress: string) { + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: `/nfts/contracts/solana/${tokenAddress}/1`, + }); + const response = await this.#fetch(url, { + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + version: '1', + }, + }); + const data = await response.json(); + + const mappedData = this.#mapGetNftMetadataResponse(data); + + return mappedData; + } catch (error) { + await trackError(error); + return null; + } + } + + async getNftMetadata(tokenAddress: string) { + return useCache(this.#getNftMetadata_INTERNAL.bind(this), this.#cache, { + functionName: 'NftApiClient:getNftMetadata', + ttlMilliseconds: this.#cacheTtlsMilliseconds.getNftMetadata, + })(tokenAddress); + } + + async getNftsMetadata(tokenAddresses: string[]) { + const nftsMetadata = await Promise.all( + tokenAddresses.map(async (tokenAddress) => + this.getNftMetadata(tokenAddress), + ), + ); + + return nftsMetadata; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseMapped.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseMapped.ts new file mode 100644 index 00000000..a1f09272 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseMapped.ts @@ -0,0 +1,40 @@ +import type { Nft } from '../types'; + +export const MOCK_NFT_METADATA_RESPONSE_MAPPED: Nft = { + address: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + tokenId: null, + tokenStandard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + mediaUrl: null, + externalUrl: '', + attributes: [ + { + key: 'Sky', + value: 'Uncharted darkness', + }, + { + key: 'Accent', + value: 'Starship', + }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collectionName: 'AB Open #02', + collectionSymbol: 'ab_open_02', + collectionCount: 1076387, + collectionImageUrl: null, + onchainCollectionAddress: 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floorPrice: null, + lastSalePrice: null, + rarity: null, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseRaw.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseRaw.ts new file mode 100644 index 00000000..e6bd361b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftMetadataResponseRaw.ts @@ -0,0 +1,40 @@ +import type { NftApiItemType } from '../types'; + +export const MOCK_NFT_METADATA_RESPONSE_RAW: NftApiItemType = { + address: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + token_id: null, + token_standard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + media_url: null, + external_url: '', + attributes: [ + { + key: 'Sky', + value: 'Uncharted darkness', + }, + { + key: 'Accent', + value: 'Starship', + }, + ], + token_account_address: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collection_name: 'AB Open #02', + collection_symbol: 'ab_open_02', + collection_count: 1076387, + collection_image_url: null, + onchain_collection_address: 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floor_price: null, + last_sale_price: null, + rarity: null, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseMapped.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseMapped.ts new file mode 100644 index 00000000..0aca99d2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseMapped.ts @@ -0,0 +1,6251 @@ +export const MOCK_NFTS_LIST_RESPONSE_MAPPED = { + cursor: '78f58ef3ed72f146b24e2eebde142a2f', + error: null, + items: [ + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: true, + nftToken: { + address: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + tokenId: null, + tokenStandard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + mediaUrl: null, + externalUrl: '', + attributes: [ + { key: 'Sky', value: 'Uncharted darkness' }, + { key: 'Accent', value: 'Starship' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collectionName: 'AB Open #02', + collectionSymbol: 'ab_open_02', + collectionCount: 1076387, + collectionImageUrl: null, + onchainCollectionAddress: 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'FsKvd5n7mrMj1sB4mSMe1B3fBqXftE3hNJdFdT4WzZhr', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: true, + nftToken: { + address: 'FsKvd5n7mrMj1sB4mSMe1B3fBqXftE3hNJdFdT4WzZhr', + tokenId: null, + tokenStandard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + mediaUrl: null, + externalUrl: '', + attributes: [ + { key: 'Sky', value: 'Uncharted darkness' }, + { key: 'Accent', value: 'Starship' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collectionName: 'AB Open #02', + collectionSymbol: 'ab_open_02', + collectionCount: 1076387, + collectionImageUrl: null, + onchainCollectionAddress: 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'EbedVEfLHYQpmVW3ay757PPz5jCjfc42ofPqgp21zhA6', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'EbedVEfLHYQpmVW3ay757PPz5jCjfc42ofPqgp21zhA6', + tokenId: null, + tokenStandard: 'NFT', + name: 'Ticket #11', + description: '', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmSfFas4yL7VB5c6XhSWUsLReuhjnECuTsGtukDLnDmQuh', + mediaUrl: null, + externalUrl: 'https://jplegacy.example', + attributes: [ + { key: 'Background', value: 'Midnight Cityscape' }, + { key: 'Suit Color', value: 'Neo Teal' }, + { key: 'Helmet Glow', value: 'Pulsating Neon' }, + { key: 'Galaxy Light', value: 'Aurora Blues' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'Aqig7DrpvaGMFoivYp7gCJLBXq6vMXj1c7p2rEcXdUh2', + share: 100, + verified: 0, + }, + ], + collectionName: 'Ticket #11', + collectionSymbol: 'ticket_11', + collectionCount: 169998, + collectionImageUrl: null, + onchainCollectionAddress: + '9wGQpooVgVRKhKzazRjRYNmaVwXDHAXFNKaXJNaKSCek', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'HMriMWAZyExwmJ28588fHstUuSBgLtD5tfeZJZTX2NxY', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'HMriMWAZyExwmJ28588fHstUuSBgLtD5tfeZJZTX2NxY', + tokenId: null, + tokenStandard: 'NFT', + name: 'Ticket #11', + description: '', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmSfFas4yL7VB5c6XhSWUsLReuhjnECuTsGtukDLnDmQuh', + mediaUrl: null, + externalUrl: 'https://jplegacy.example', + attributes: [ + { key: 'Background', value: 'Midnight Cityscape' }, + { key: 'Suit Color', value: 'Neo Teal' }, + { key: 'Helmet Glow', value: 'Pulsating Neon' }, + { key: 'Galaxy Light', value: 'Aurora Blues' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'Aqig7DrpvaGMFoivYp7gCJLBXq6vMXj1c7p2rEcXdUh2', + share: 100, + verified: 0, + }, + ], + collectionName: 'Ticket #11', + collectionSymbol: 'ticket_11', + collectionCount: 169998, + collectionImageUrl: null, + onchainCollectionAddress: + '9wGQpooVgVRKhKzazRjRYNmaVwXDHAXFNKaXJNaKSCek', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2Q9qk8eSMnFAaLFqedSzYgbYa4oNcxBC99joYFeUm4ur', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: true, + nftToken: { + address: '2Q9qk8eSMnFAaLFqedSzYgbYa4oNcxBC99joYFeUm4ur', + tokenId: null, + tokenStandard: 'NFT', + name: '#7', + description: 'Unlock 1000 $ТRUMР at ТRUMPСHEST​.​СOM', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmZ1mKs7hZEgK7f4j4r2YG7VwRsvNc2QP8Z6c2Ucp5Tja2', + mediaUrl: null, + externalUrl: '', + attributes: [ + { key: '', value: '' }, + { key: '', value: '' }, + { key: '', value: '' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '7kCL2HxbcvoVvJaF7awjznm36CkPbz11mKk9wn6oySQQ', + share: 100, + verified: 0, + }, + ], + collectionName: '$ТRUMР #01280', + collectionSymbol: 'rum_01280', + collectionCount: 2387471, + collectionImageUrl: null, + onchainCollectionAddress: + '6Yw2PXmCTsXqdX2HxHsBwSMEaag7nSjQjWpCdAu5sbj2', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '7BkNG96HhRgAL3hqSUybyeydoVHpD9zFaaNAVbUScXJK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: true, + nftToken: { + address: '7BkNG96HhRgAL3hqSUybyeydoVHpD9zFaaNAVbUScXJK', + tokenId: null, + tokenStandard: 'NFT', + name: '#7', + description: 'Unlock 1000 $ТRUMР at ТRUMPСHEST​.​СOM', + metadata: null, + imageUrl: + 'https://ipfs.io/ipfs/QmZ1mKs7hZEgK7f4j4r2YG7VwRsvNc2QP8Z6c2Ucp5Tja2', + mediaUrl: null, + externalUrl: '', + attributes: [ + { key: '', value: '' }, + { key: '', value: '' }, + { key: '', value: '' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '7kCL2HxbcvoVvJaF7awjznm36CkPbz11mKk9wn6oySQQ', + share: 100, + verified: 0, + }, + ], + collectionName: '$ТRUMР #01280', + collectionSymbol: 'rum_01280', + collectionCount: 2387471, + collectionImageUrl: null, + onchainCollectionAddress: + '6Yw2PXmCTsXqdX2HxHsBwSMEaag7nSjQjWpCdAu5sbj2', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '98yhmwEkWNDuTaQiUTSbCuAot4rH9vzxkFrDb2EEL6Cm', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '98yhmwEkWNDuTaQiUTSbCuAot4rH9vzxkFrDb2EEL6Cm', + tokenId: null, + tokenStandard: 'NFT', + name: 'Spaceship #11265', + description: + 'The Coalition uncovered a wide variety of issues when trying to expand their influence in the new galaxy, learning a great deal from their mistakes in the process. When the opportunity to construct new exploration vessels finally arrived, the engineers knew what the people of the Coalition needed. Powerful engines, pinpoint sensory equipment, cargo and fuel capacity upgrades would all ensure success even in particularly complicated scenarios.', + metadata: null, + imageUrl: + 'https://s3.projectnebula.com/collection/pn-ship/ship-11265.jpg', + mediaUrl: null, + externalUrl: 'https://play.projectnebula.com/ship/11265', + attributes: [ + { key: 'Ship class', value: 'Exemplar' }, + { key: 'Ship type', value: 'Exploration vessel' }, + { key: 'Tier', value: 'I' }, + { key: 'Generation', value: 'GEN-2' }, + { key: 'Background', value: 'Paragon' }, + { key: 'Coating', value: 'Famous Fox Federation' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'SPACLeJShFiF9YHgj7PHgSk93ZvkFGE7hEwK63G2Dq9', + share: 100, + verified: 0, + }, + ], + collectionName: 'Project Nebula Spaceships', + collectionSymbol: 'project_nebula_spaceship', + collectionCount: 12061, + collectionImageUrl: null, + onchainCollectionAddress: + 'GNA7ev6TkKN2iCQd8ePGEDb36XvYr7hpKm4nmejtPCoZ', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2bizwWM2QAtXwxcAoYuVjJ1W65rWQKcjujBQdYHVyr4m', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '2bizwWM2QAtXwxcAoYuVjJ1W65rWQKcjujBQdYHVyr4m', + tokenId: null, + tokenStandard: 'NFT', + name: 'For You 5000Jup 5000Jup.com', + description: + 'Visit the domain shown in the picture and claim your exclusive voucher 5000jup.com', + metadata: null, + imageUrl: 'https://img.hi-hi.vip/json/img/5000jup1.png', + mediaUrl: null, + externalUrl: 'https://5000jup.com', + attributes: [ + { key: 'Website', value: 'https://5000jup.com' }, + { key: 'Verified', value: 'True' }, + { key: 'Amount', value: '5,000+ JUP ($5000+)' }, + { key: 'Time Left', value: '30 minutes!' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'Gx31E1GUzEeqjCHPtyLkNQHCqwi6REpyp66Hi262vCtG', + share: 100, + verified: 1, + }, + ], + collectionName: 'For You 5000Jup', + collectionSymbol: 'for_you_5000jup', + collectionCount: 0, + collectionImageUrl: null, + onchainCollectionAddress: 'hD6sEXnP9ihfeGKfRknKR5zZqE7upXJ7sJd4DZoM6WJ', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '7LrcVUyRqS1nSUvoBxoCy1iXEGYG6W9X7LRKsuai254K', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '7LrcVUyRqS1nSUvoBxoCy1iXEGYG6W9X7LRKsuai254K', + tokenId: null, + tokenStandard: 'NFT', + name: 'Golden Shorts Bingo Cards', + description: + 'Official Bingo Cards & Mint Pass of the Golden Shorts Rascal Club. #77903', + metadata: null, + imageUrl: + 'https://bafybeiarry26winniyzce7gzzlidpkgs2xbcwevv7jmq4ifnqvlygungm4.ipfs.w3s.link/3511.png', + mediaUrl: null, + externalUrl: '', + attributes: [ + { key: 'Background', value: 'rascals_whitelist_bingo' }, + { key: 'B', value: '8b$100' }, + { key: 'I', value: '8a$100' }, + { key: 'N', value: 'n32453844' }, + { key: 'G', value: 'g54555351' }, + { key: 'O', value: 'o626371' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'A2b2NA5QADSUQkjVqTmz2HzsRyYECgKxcrT2x4owxHoZ', + share: 100, + verified: 0, + }, + ], + collectionName: 'GOLDEN RASCALS BINGO WL EDITION', + collectionSymbol: 'bingo', + collectionCount: 62261, + collectionImageUrl: null, + onchainCollectionAddress: + 'CvsKkZM6UAvCJBF2SGLfR8di52ZhYscg9zBNzCfkB19H', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'DyBtina3CQzQXeQz9HxSWZj5ku6ta7DrHhwnowDygAFA', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'DyBtina3CQzQXeQz9HxSWZj5ku6ta7DrHhwnowDygAFA', + tokenId: null, + tokenStandard: 'NFT', + name: 'Choizzy Pass #6', + description: + 'Choizzy Passes are NFTs that grant whitelist access to the Choizzy Main mint. They also serve as a reward for our fantastic early adopters. Hold tight!', + metadata: null, + imageUrl: + 'https://bafkreigv5rks54dgimzi764io7tfavlwlwav7qcicuokhox2ve4yynj54a.ipfs.nftstorage.link/', + mediaUrl: null, + externalUrl: 'https://choizzy.io', + attributes: [ + { key: 'Hoodie Color', value: 'Green' }, + { key: 'Eyes', value: 'Regular' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'DK2iYsdgpYoZB9EPJrffz2XyuLBPkJjdUjsrCyZqg4U1', + share: 0, + verified: 1, + }, + { + address: '3eanCCeoApEmJUhmKsfWHgjrBVedQRrPJzAZUstiEnMH', + share: 100, + verified: 0, + }, + ], + collectionName: 'Choizzy Pass', + collectionSymbol: 'choizzy_pass', + collectionCount: 1999, + collectionImageUrl: null, + onchainCollectionAddress: + '4GW29XZQTtk4iKttRdUF1MLEc6gRYhYH8BPd6jfLSGp4', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '69MSbRdmkfW5AiAHNGcufqmocVxgrss3Nq1QvqZKg6Pf', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '69MSbRdmkfW5AiAHNGcufqmocVxgrss3Nq1QvqZKg6Pf', + tokenId: null, + tokenStandard: 'NFT', + name: 'Primes Airdrop Box', + description: + 'The Primes Airdrop Boxes is your chance on getting the $PRM Token before everyone else. Boxes can now be burnt on The Primes official website.', + metadata: null, + imageUrl: + 'https://shdw-drive.genesysgo.net/2MYtdX5Bwz68aVny1cHqRyujAJkNEzknQyF1SNav8dTh/1.png', + mediaUrl: null, + externalUrl: 'https://theprimes.io', + attributes: [ + { key: 'Revealed', value: 'Pending' }, + { key: 'Claim At', value: 'https://theprimes.io/burn2earn' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'pxmVowVaqXn23krwFxjzKC6UW4YjbYEQUqYC6q1Ta57', + share: 100, + verified: 0, + }, + ], + collectionName: 'Primes Airdrop Boxes', + collectionSymbol: 'primes_airdrop_boxes', + collectionCount: 69425, + collectionImageUrl: null, + onchainCollectionAddress: null, + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '7GEi7ytc31LPcyPtBd9WCdPm4P71W9iV2sUSJHW7BuN2', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '7GEi7ytc31LPcyPtBd9WCdPm4P71W9iV2sUSJHW7BuN2', + tokenId: null, + tokenStandard: 'NFT', + name: 'Primes Airdrop Box', + description: + 'The Primes Airdrop Boxes is your chance on getting the $PRM Token before everyone else. Boxes can now be burnt on The Primes official website.', + metadata: null, + imageUrl: + 'https://shdw-drive.genesysgo.net/2MYtdX5Bwz68aVny1cHqRyujAJkNEzknQyF1SNav8dTh/1.png', + mediaUrl: null, + externalUrl: 'https://theprimes.io', + attributes: [ + { key: 'Revealed', value: 'Pending' }, + { key: 'Claim At', value: 'https://theprimes.io/burn2earn' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: 'pxmVowVaqXn23krwFxjzKC6UW4YjbYEQUqYC6q1Ta57', + share: 100, + verified: 0, + }, + ], + collectionName: 'Primes Airdrop Boxes', + collectionSymbol: 'primes_airdrop_boxes', + collectionCount: 69425, + collectionImageUrl: null, + onchainCollectionAddress: null, + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GgzC3fbvWsq83XLj1C7VK3q8Fy7HDbU15RuJCb4EfVMF', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GgzC3fbvWsq83XLj1C7VK3q8Fy7HDbU15RuJCb4EfVMF', + tokenId: null, + tokenStandard: 'NFT', + name: 'Utamu Slate 65', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/1FsiYUkck9I058bkJtvgj93xFewLVJC6VhEqoMT7aQU', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '65' }, + { key: 'Smile', value: '45' }, + { key: 'Hug', value: '46' }, + { key: 'Laugh', value: '72' }, + { key: 'Heart', value: '85' }, + { key: 'Warm', value: '69' }, + { key: 'Good', value: '70' }, + { key: 'Thanks', value: '64' }, + { key: 'Share', value: '81' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '18' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'Gon7ttJSjAc9dkzayZzWwtsNKDf5mqz3gWYJs8VDDwHa', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'Gon7ttJSjAc9dkzayZzWwtsNKDf5mqz3gWYJs8VDDwHa', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rirewa Copper 33', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/GCJMQDpY6QE6ZkDGBiWQvojWdgHCG-vT-AzjBK06Qpg', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '33' }, + { key: 'Smile', value: '50' }, + { key: 'Hug', value: '29' }, + { key: 'Laugh', value: '22' }, + { key: 'Heart', value: '32' }, + { key: 'Warm', value: '44' }, + { key: 'Good', value: '42' }, + { key: 'Thanks', value: '41' }, + { key: 'Share', value: '32' }, + { key: 'Brave', value: '36' }, + { key: 'Grow', value: '2' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '77Q9CohBDAtcVViu8omnGPRuZ3NLEJmWTQSxS4NkNTb4', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '77Q9CohBDAtcVViu8omnGPRuZ3NLEJmWTQSxS4NkNTb4', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kukona Moss 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/CuBSyOFg1INUzBQYHXc2RJjeGnHq9-Z_y_aCRpPYBic', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Moss' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '41' }, + { key: 'Smile', value: '43' }, + { key: 'Hug', value: '25' }, + { key: 'Laugh', value: '29' }, + { key: 'Heart', value: '43' }, + { key: 'Warm', value: '22' }, + { key: 'Good', value: '59' }, + { key: 'Thanks', value: '58' }, + { key: 'Share', value: '52' }, + { key: 'Brave', value: '4' }, + { key: 'Grow', value: '75' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BEuGqAUFfxUokCpKWrt4vfWL9LVo2Eqrz5bHR2W7VVdY', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BEuGqAUFfxUokCpKWrt4vfWL9LVo2Eqrz5bHR2W7VVdY', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kukua Crimson 14', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/niam-C8liKefeRqrRvHLNT5ylrXGc6bMyvXndQ37LQM', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '14' }, + { key: 'Smile', value: '17' }, + { key: 'Hug', value: '21' }, + { key: 'Laugh', value: '34' }, + { key: 'Heart', value: '1' }, + { key: 'Warm', value: '24' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '5' }, + { key: 'Share', value: '12' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '24' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'UAdBHhEbn4e5EGu4JGi3jUocysjCTvfwafaLUPXjHEx', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'UAdBHhEbn4e5EGu4JGi3jUocysjCTvfwafaLUPXjHEx', + tokenId: null, + tokenStandard: 'NFT', + name: 'Worika Atlantis 87', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Z-bNgDExYdMNG2RJhxe-eCEt1DCcZ3lV5Q723fiLfpE', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Violet Gold Blaze' }, + { key: 'Background', value: 'Atlantis' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '87' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '87' }, + { key: 'Laugh', value: '90' }, + { key: 'Heart', value: '94' }, + { key: 'Warm', value: '90' }, + { key: 'Good', value: '77' }, + { key: 'Thanks', value: '83' }, + { key: 'Share', value: '95' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '65' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '97vAn6ZDWZyDzaa2ewvrNzeebr349DbufMHmboQiKpoQ', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '97vAn6ZDWZyDzaa2ewvrNzeebr349DbufMHmboQiKpoQ', + tokenId: null, + tokenStandard: 'NFT', + name: 'Fumemi Umber 53', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/9PppdeVSQVwDnI35yOfSdFB0Np-d98HPGntDiigzG0g', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '53' }, + { key: 'Smile', value: '53' }, + { key: 'Hug', value: '38' }, + { key: 'Laugh', value: '68' }, + { key: 'Heart', value: '53' }, + { key: 'Warm', value: '56' }, + { key: 'Good', value: '63' }, + { key: 'Thanks', value: '41' }, + { key: 'Share', value: '68' }, + { key: 'Brave', value: '81' }, + { key: 'Grow', value: '9' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'AN9wcB4HifAz8Tze6ttKDroLtPEPfnQVhxebh5wcqV1X', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'AN9wcB4HifAz8Tze6ttKDroLtPEPfnQVhxebh5wcqV1X', + tokenId: null, + tokenStandard: 'NFT', + name: 'Waware Copper 75', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreifrxhngg6slpjuvprxtsgtxfjsmeiimraplsihp47r5zaq4ucmpyu', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '75' }, + { key: 'Smile', value: '82' }, + { key: 'Hug', value: '74' }, + { key: 'Laugh', value: '82' }, + { key: 'Heart', value: '84' }, + { key: 'Warm', value: '77' }, + { key: 'Good', value: '80' }, + { key: 'Thanks', value: '77' }, + { key: 'Share', value: '73' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '21' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'EBjLNh3EUycToAex1da7FVEWyXbpHk9GdQPEJvZqoFhY', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'EBjLNh3EUycToAex1da7FVEWyXbpHk9GdQPEJvZqoFhY', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rikime Blush 50', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/HOhNC_W705buA9TDbiEcEhLTmihNRZALn-BBGcyBhbo', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '50' }, + { key: 'Smile', value: '59' }, + { key: 'Hug', value: '43' }, + { key: 'Laugh', value: '54' }, + { key: 'Heart', value: '39' }, + { key: 'Warm', value: '54' }, + { key: 'Good', value: '57' }, + { key: 'Thanks', value: '47' }, + { key: 'Share', value: '63' }, + { key: 'Brave', value: '30' }, + { key: 'Grow', value: '54' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'DGQwzn1DNzyTKYZW6c7PSshGfi8BeU5YhkYtoea7xYuz', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'DGQwzn1DNzyTKYZW6c7PSshGfi8BeU5YhkYtoea7xYuz', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kemata Azure 46', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/ZDe1CpPqRMw1lWCbabiMCicDDgCLlRh3sN3-Um4lX8U', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '46' }, + { key: 'Smile', value: '51' }, + { key: 'Hug', value: '26' }, + { key: 'Laugh', value: '49' }, + { key: 'Heart', value: '43' }, + { key: 'Warm', value: '38' }, + { key: 'Good', value: '46' }, + { key: 'Thanks', value: '64' }, + { key: 'Share', value: '34' }, + { key: 'Brave', value: '41' }, + { key: 'Grow', value: '68' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '9NP82fWe2gw7F1wNeFWp8Ytcz3x5H8FH5w2CJr9evk3T', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '9NP82fWe2gw7F1wNeFWp8Ytcz3x5H8FH5w2CJr9evk3T', + tokenId: null, + tokenStandard: 'NFT', + name: 'Sakotsu Umber 32', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/s9SCmcGZl4sDejvngTKsXI3Bp5o-3_EfVXh5vQfy0kI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '32' }, + { key: 'Smile', value: '15' }, + { key: 'Hug', value: '32' }, + { key: 'Laugh', value: '29' }, + { key: 'Heart', value: '25' }, + { key: 'Warm', value: '22' }, + { key: 'Good', value: '25' }, + { key: 'Thanks', value: '36' }, + { key: 'Share', value: '31' }, + { key: 'Brave', value: '7' }, + { key: 'Grow', value: '98' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GjMQxQYGgB4dmPUyox6Y8gPscc4xcPR1nCfBRbXxKfv3', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GjMQxQYGgB4dmPUyox6Y8gPscc4xcPR1nCfBRbXxKfv3', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rikike Blush 33', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/jx68DdlTNH-oA9NJndW0uo7y3ntTKklR094oAGF_-x4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '33' }, + { key: 'Smile', value: '26' }, + { key: 'Hug', value: '39' }, + { key: 'Laugh', value: '43' }, + { key: 'Heart', value: '23' }, + { key: 'Warm', value: '28' }, + { key: 'Good', value: '45' }, + { key: 'Thanks', value: '39' }, + { key: 'Share', value: '26' }, + { key: 'Brave', value: '37' }, + { key: 'Grow', value: '24' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BMhpzoc1oZbqoxiifrr8pyCVDW1G1pFa4YrrqnZ1Q5By', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BMhpzoc1oZbqoxiifrr8pyCVDW1G1pFa4YrrqnZ1Q5By', + tokenId: null, + tokenStandard: 'NFT', + name: 'Norae Slate 49', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/tjDj3ySxgnjLHUF6PkL_I2_x_D7-O0Rvq2jMoMf-XDY', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '49' }, + { key: 'Smile', value: '43' }, + { key: 'Hug', value: '60' }, + { key: 'Laugh', value: '64' }, + { key: 'Heart', value: '54' }, + { key: 'Warm', value: '45' }, + { key: 'Good', value: '38' }, + { key: 'Thanks', value: '31' }, + { key: 'Share', value: '38' }, + { key: 'Brave', value: '51' }, + { key: 'Grow', value: '66' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'DUar8NL7do1KHdu3a8S5gr9W3rcQ8uRJ7D8oxbMagGZH', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'DUar8NL7do1KHdu3a8S5gr9W3rcQ8uRJ7D8oxbMagGZH', + tokenId: null, + tokenStandard: 'NFT', + name: 'Remima Steel 36', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/JjCXf-xvfAr7x69zh99VkIf97bCT6UCg7BV3zHX8Fdk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Steel' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '36' }, + { key: 'Smile', value: '38' }, + { key: 'Hug', value: '36' }, + { key: 'Laugh', value: '17' }, + { key: 'Heart', value: '34' }, + { key: 'Warm', value: '47' }, + { key: 'Good', value: '41' }, + { key: 'Thanks', value: '54' }, + { key: 'Share', value: '22' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '70' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'CS1EPRuna4uFjMyx8SsTQbSizK1bYNdafjS81sqt78Yb', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'CS1EPRuna4uFjMyx8SsTQbSizK1bYNdafjS81sqt78Yb', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nifue Blush 49', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/dCq8MRBzYrT6btmMuB8REAEofLbjsTZmAim2rwCVmVs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '49' }, + { key: 'Smile', value: '41' }, + { key: 'Hug', value: '39' }, + { key: 'Laugh', value: '32' }, + { key: 'Heart', value: '32' }, + { key: 'Warm', value: '56' }, + { key: 'Good', value: '55' }, + { key: 'Thanks', value: '53' }, + { key: 'Share', value: '50' }, + { key: 'Brave', value: '42' }, + { key: 'Grow', value: '90' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GBhfyvjTDLVxbUrgiFw9vmTx2YAomQKM7DchPAfXkykM', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GBhfyvjTDLVxbUrgiFw9vmTx2YAomQKM7DchPAfXkykM', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kohatsu Azure 9', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/G-FAkEF-lbMVz0hGH0Y-s-J_shnUwoP56qJ3qSCoz-Y', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Rose' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '9' }, + { key: 'Smile', value: '7' }, + { key: 'Hug', value: '1' }, + { key: 'Laugh', value: '10' }, + { key: 'Heart', value: '4' }, + { key: 'Warm', value: '10' }, + { key: 'Good', value: '7' }, + { key: 'Thanks', value: '7' }, + { key: 'Share', value: '15' }, + { key: 'Brave', value: '7' }, + { key: 'Grow', value: '22' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '51qZDbaqtLrMpWuKsEDr2LxmWmvoXqWQKasG2U5JEgJ', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '51qZDbaqtLrMpWuKsEDr2LxmWmvoXqWQKasG2U5JEgJ', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hohana Black 99', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/0R7VcDSywaw2E0EmRvnQUFMYX3jyVcRkR3bJudpFqVU', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Black' }, + { key: 'Rarity', value: 'Legendary' }, + { key: 'Rating', value: '99' }, + { key: 'Smile', value: '100' }, + { key: 'Hug', value: '95' }, + { key: 'Laugh', value: '94' }, + { key: 'Heart', value: '98' }, + { key: 'Warm', value: '91' }, + { key: 'Good', value: '95' }, + { key: 'Thanks', value: '96' }, + { key: 'Share', value: '91' }, + { key: 'Brave', value: '94' }, + { key: 'Grow', value: '91' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'H91LyFi3XKxMKbks6NgMAHv6kgFo2ZmJVM7QCqa5ibAz', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'H91LyFi3XKxMKbks6NgMAHv6kgFo2ZmJVM7QCqa5ibAz', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hokita Copper 8', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/nCiXVEca5CACx5AUbEgBXZuV0tmCDwMesXDHlMPNabs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '8' }, + { key: 'Smile', value: '1' }, + { key: 'Hug', value: '1' }, + { key: 'Laugh', value: '14' }, + { key: 'Heart', value: '8' }, + { key: 'Warm', value: '1' }, + { key: 'Good', value: '10' }, + { key: 'Thanks', value: '1' }, + { key: 'Share', value: '9' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '34' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2EnwiirRkhuiqru8s8si6gB2TPuc6BiUSQR7mAjFmGbi', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '2EnwiirRkhuiqru8s8si6gB2TPuc6BiUSQR7mAjFmGbi', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nakiu Black 100', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/e0KFwCk42RjRVYDGHTzmER9DBp3k6tDJjxGvpDHf6Ys', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Black' }, + { key: 'Rarity', value: 'Legendary' }, + { key: 'Rating', value: '100' }, + { key: 'Smile', value: '100' }, + { key: 'Hug', value: '98' }, + { key: 'Laugh', value: '95' }, + { key: 'Heart', value: '92' }, + { key: 'Warm', value: '91' }, + { key: 'Good', value: '95' }, + { key: 'Thanks', value: '99' }, + { key: 'Share', value: '90' }, + { key: 'Brave', value: '99' }, + { key: 'Grow', value: '94' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '3trwQkC9AnrsRwMJTXCj9TaaCYnULzbwjdnVu1M3Tg8n', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '3trwQkC9AnrsRwMJTXCj9TaaCYnULzbwjdnVu1M3Tg8n', + tokenId: null, + tokenStandard: 'NFT', + name: 'Morate Slate 23', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/FzUJDcW2a37XolloFxvLJE-e4S60Mg_X-cT3-UPHPWk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '23' }, + { key: 'Smile', value: '38' }, + { key: 'Hug', value: '22' }, + { key: 'Laugh', value: '4' }, + { key: 'Heart', value: '16' }, + { key: 'Warm', value: '15' }, + { key: 'Good', value: '16' }, + { key: 'Thanks', value: '19' }, + { key: 'Share', value: '33' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '66' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BgXSiLx46j66K3dPKrkEzqpNah6yPLVCdMaUNexJkBuR', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BgXSiLx46j66K3dPKrkEzqpNah6yPLVCdMaUNexJkBuR', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kanari Umber 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/us3c52RJ0zE1APAZ-Ru7ZwC22xWIQblnkecg1JAhUsk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '30' }, + { key: 'Smile', value: '45' }, + { key: 'Hug', value: '39' }, + { key: 'Laugh', value: '16' }, + { key: 'Heart', value: '35' }, + { key: 'Warm', value: '18' }, + { key: 'Good', value: '22' }, + { key: 'Thanks', value: '28' }, + { key: 'Share', value: '11' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '85' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'DLAadn6cwU3zEWfdT8ysG9aYZX3dREcD4Zq7tu7oaWaY', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'DLAadn6cwU3zEWfdT8ysG9aYZX3dREcD4Zq7tu7oaWaY', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rusese Azure 78', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/cA09R_D2QlZqwM47bb7hj0JqO7RJ3vfY2pfEbOFn0nk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '78' }, + { key: 'Smile', value: '84' }, + { key: 'Hug', value: '81' }, + { key: 'Laugh', value: '73' }, + { key: 'Heart', value: '79' }, + { key: 'Warm', value: '88' }, + { key: 'Good', value: '72' }, + { key: 'Thanks', value: '88' }, + { key: 'Share', value: '68' }, + { key: 'Brave', value: '94' }, + { key: 'Grow', value: '53' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'rfsqYespebrV4LHpc9bBrjVizZdtsFLMn3u8BrH8WA2', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'rfsqYespebrV4LHpc9bBrjVizZdtsFLMn3u8BrH8WA2', + tokenId: null, + tokenStandard: 'NFT', + name: 'Suresa Umber 24', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreigohb7vkelgqdvuozf77tsbfzgyoreb7kfdgzwpxspp5npqbgwlwq', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Coral' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '24' }, + { key: 'Smile', value: '32' }, + { key: 'Hug', value: '21' }, + { key: 'Laugh', value: '13' }, + { key: 'Heart', value: '20' }, + { key: 'Warm', value: '40' }, + { key: 'Good', value: '23' }, + { key: 'Thanks', value: '7' }, + { key: 'Share', value: '18' }, + { key: 'Brave', value: '38' }, + { key: 'Grow', value: '28' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4c8w7UELpYiLSebt9LBDHwqo1sSms6AHQPhLaGchDmXK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4c8w7UELpYiLSebt9LBDHwqo1sSms6AHQPhLaGchDmXK', + tokenId: null, + tokenStandard: 'NFT', + name: 'Korihi Umber 77', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/pamBCJnH9I7UEiCZLTE5991Y3OGlDWi9hs-8LcdY9Ew', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '77' }, + { key: 'Smile', value: '85' }, + { key: 'Hug', value: '85' }, + { key: 'Laugh', value: '74' }, + { key: 'Heart', value: '75' }, + { key: 'Warm', value: '77' }, + { key: 'Good', value: '71' }, + { key: 'Thanks', value: '76' }, + { key: 'Share', value: '68' }, + { key: 'Brave', value: '69' }, + { key: 'Grow', value: '90' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'ATD2ZB348yVE3TmLdpTCG5RCNWVDv6fvVxbbUWZ4bMuD', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'ATD2ZB348yVE3TmLdpTCG5RCNWVDv6fvVxbbUWZ4bMuD', + tokenId: null, + tokenStandard: 'NFT', + name: 'Niroro Blush 78', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/mF-GKGokpgVTeRbx4l-GrPnZNynOaNK5D7euuclgiTI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Violet' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '78' }, + { key: 'Smile', value: '88' }, + { key: 'Hug', value: '68' }, + { key: 'Laugh', value: '68' }, + { key: 'Heart', value: '87' }, + { key: 'Warm', value: '88' }, + { key: 'Good', value: '68' }, + { key: 'Thanks', value: '81' }, + { key: 'Share', value: '79' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '53' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5NER4zk67boQSyBRpXkctXx9spWdCWghjvLKAJRUqMqh', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5NER4zk67boQSyBRpXkctXx9spWdCWghjvLKAJRUqMqh', + tokenId: null, + tokenStandard: 'NFT', + name: 'Yoohe Steel 23', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/5FywtmfMyZpuEkiOuMznAkbonLXVEp7CVd07or738XI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Steel' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '23' }, + { key: 'Smile', value: '30' }, + { key: 'Hug', value: '41' }, + { key: 'Laugh', value: '8' }, + { key: 'Heart', value: '4' }, + { key: 'Warm', value: '30' }, + { key: 'Good', value: '5' }, + { key: 'Thanks', value: '7' }, + { key: 'Share', value: '27' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '77' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '77NqHK3N4iMb5h3WdJ97m9ZwvTo2E5kGii3vJ4CgMcjV', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '77NqHK3N4iMb5h3WdJ97m9ZwvTo2E5kGii3vJ4CgMcjV', + tokenId: null, + tokenStandard: 'NFT', + name: 'Yusuno Slate 27', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/yLEJcqvDJY-qpltJo1fMlxMJ87S3qMLtFN1pFvgXQSQ', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '27' }, + { key: 'Smile', value: '8' }, + { key: 'Hug', value: '21' }, + { key: 'Laugh', value: '20' }, + { key: 'Heart', value: '37' }, + { key: 'Warm', value: '29' }, + { key: 'Good', value: '10' }, + { key: 'Thanks', value: '39' }, + { key: 'Share', value: '38' }, + { key: 'Brave', value: '7' }, + { key: 'Grow', value: '61' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '47v8QuQVbi1vMpQPD1omtD576azLVLnZRDKnbMRE4UVc', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '47v8QuQVbi1vMpQPD1omtD576azLVLnZRDKnbMRE4UVc', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nuwase Lilac 75', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreieibgslgahlx7msh7nygks3b5ootzbaz7tgeduss5nogylcgxhufu', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Lilac' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '75' }, + { key: 'Smile', value: '71' }, + { key: 'Hug', value: '83' }, + { key: 'Laugh', value: '66' }, + { key: 'Heart', value: '80' }, + { key: 'Warm', value: '72' }, + { key: 'Good', value: '77' }, + { key: 'Thanks', value: '69' }, + { key: 'Share', value: '72' }, + { key: 'Brave', value: '62' }, + { key: 'Grow', value: '98' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4hnxWEfvWRPDECXivcd4KQ8b72f7EpTLW3ahLfTsE1SK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4hnxWEfvWRPDECXivcd4KQ8b72f7EpTLW3ahLfTsE1SK', + tokenId: null, + tokenStandard: 'NFT', + name: 'Suniku Volcano 91', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/68sOcz3f7BeI0TwlYi3akg2lvOJKmICZeviz9Mg0IOA', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold Blaze Rose' }, + { key: 'Background', value: 'Volcano' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '91' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '88' }, + { key: 'Laugh', value: '100' }, + { key: 'Heart', value: '91' }, + { key: 'Warm', value: '98' }, + { key: 'Good', value: '82' }, + { key: 'Thanks', value: '94' }, + { key: 'Share', value: '85' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '83' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '6xc3gCdFiXjaThH9VPeLE4THp7spDvrbuQJSjdNAq8ZC', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '6xc3gCdFiXjaThH9VPeLE4THp7spDvrbuQJSjdNAq8ZC', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nimira Azure 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/aHXL3LNLt5yVs4YlNQf85g7Ze4pSRusQ2tvjV29bWC0', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '41' }, + { key: 'Smile', value: '51' }, + { key: 'Hug', value: '56' }, + { key: 'Laugh', value: '46' }, + { key: 'Heart', value: '38' }, + { key: 'Warm', value: '34' }, + { key: 'Good', value: '54' }, + { key: 'Thanks', value: '42' }, + { key: 'Share', value: '55' }, + { key: 'Brave', value: '29' }, + { key: 'Grow', value: '5' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'AoWSoRA7fKd1siZzzJyDwP1wmGrgTy4XaxPStPzRSCqC', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'AoWSoRA7fKd1siZzzJyDwP1wmGrgTy4XaxPStPzRSCqC', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nofun Slate 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/P6RJ6EoXbebO-gWjTo-P1cZr85Qh1UyWSuDN2F7-BXs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Rose' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '30' }, + { key: 'Smile', value: '19' }, + { key: 'Hug', value: '32' }, + { key: 'Laugh', value: '31' }, + { key: 'Heart', value: '38' }, + { key: 'Warm', value: '48' }, + { key: 'Good', value: '14' }, + { key: 'Thanks', value: '16' }, + { key: 'Share', value: '35' }, + { key: 'Brave', value: '24' }, + { key: 'Grow', value: '43' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '8D2Yut6B6vNvGa3JmwTf8mBPjUbaP2VDRtBdjFoyTiMx', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '8D2Yut6B6vNvGa3JmwTf8mBPjUbaP2VDRtBdjFoyTiMx', + tokenId: null, + tokenStandard: 'NFT', + name: 'Tokau Nostalgia 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/VsUufMJMwyTjaoaExI87RlgNWRmYrVKTmsQVI88__qQ', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe Mint Gold' }, + { key: 'Background', value: 'Nostalgia' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '86' }, + { key: 'Smile', value: '86' }, + { key: 'Hug', value: '88' }, + { key: 'Laugh', value: '93' }, + { key: 'Heart', value: '93' }, + { key: 'Warm', value: '79' }, + { key: 'Good', value: '80' }, + { key: 'Thanks', value: '92' }, + { key: 'Share', value: '96' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '53' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '91xa3Mo4mkMV7ER6eWX7h4GH9xbBpTgVgyt36Ncqfq18', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '91xa3Mo4mkMV7ER6eWX7h4GH9xbBpTgVgyt36Ncqfq18', + tokenId: null, + tokenStandard: 'NFT', + name: 'Mesoru Lilac 81', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/t-vYtwb7BFjG0w3AvpQvwunUa4mq8PHH60c0yhiKDFU', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Lilac' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '81' }, + { key: 'Smile', value: '72' }, + { key: 'Hug', value: '81' }, + { key: 'Laugh', value: '90' }, + { key: 'Heart', value: '74' }, + { key: 'Warm', value: '85' }, + { key: 'Good', value: '83' }, + { key: 'Thanks', value: '90' }, + { key: 'Share', value: '76' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '59' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '3Z8mMqu7zGpjuU6FbA5CfitNCXr7eSc3RfTswh1AcC6t', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '3Z8mMqu7zGpjuU6FbA5CfitNCXr7eSc3RfTswh1AcC6t', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nihaho Red 98', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/EjQtl3DxC4wSRkhPpeoq2TkPGnNfUI9CVwuaWtigP-8', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Red' }, + { key: 'Rarity', value: 'Legendary' }, + { key: 'Rating', value: '98' }, + { key: 'Smile', value: '93' }, + { key: 'Hug', value: '95' }, + { key: 'Laugh', value: '94' }, + { key: 'Heart', value: '94' }, + { key: 'Warm', value: '98' }, + { key: 'Good', value: '91' }, + { key: 'Thanks', value: '95' }, + { key: 'Share', value: '91' }, + { key: 'Brave', value: '96' }, + { key: 'Grow', value: '97' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4KBDR1fKyzDAjWxdN9RtGqDrFA3w7jDHXtQ3midGXDiK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4KBDR1fKyzDAjWxdN9RtGqDrFA3w7jDHXtQ3midGXDiK', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kusete Nostalgia 88', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/r70dK6iosoPJ-e80ao_PzTC9hamuqjZtVTifyax5H5A', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime Rose Mint' }, + { key: 'Background', value: 'Nostalgia' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '88' }, + { key: 'Smile', value: '92' }, + { key: 'Hug', value: '93' }, + { key: 'Laugh', value: '94' }, + { key: 'Heart', value: '81' }, + { key: 'Warm', value: '84' }, + { key: 'Good', value: '81' }, + { key: 'Thanks', value: '93' }, + { key: 'Share', value: '87' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '75' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5yWqBNq7xBYdU3xvWuBgv5zznyY1KQuybo1X6Rn3Y5ti', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5yWqBNq7xBYdU3xvWuBgv5zznyY1KQuybo1X6Rn3Y5ti', + tokenId: null, + tokenStandard: 'NFT', + name: 'Maumi Copper 50', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Ac24TFEtdJlP8rLmwT89IP8xxFpDg_h44WtyxEla7ZQ', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '50' }, + { key: 'Smile', value: '38' }, + { key: 'Hug', value: '52' }, + { key: 'Laugh', value: '48' }, + { key: 'Heart', value: '30' }, + { key: 'Warm', value: '58' }, + { key: 'Good', value: '34' }, + { key: 'Thanks', value: '67' }, + { key: 'Share', value: '33' }, + { key: 'Brave', value: '47' }, + { key: 'Grow', value: '93' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '8Ruk1TR2pm8e6T2bMZjBqL5aFwq9BPwe9J65rzHMAcek', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '8Ruk1TR2pm8e6T2bMZjBqL5aFwq9BPwe9J65rzHMAcek', + tokenId: null, + tokenStandard: 'NFT', + name: 'Uahi Steel 35', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreiags6ha7fxshutnsfmarer6xknuri2hio7ne7lz4c5eubtkpco6mm', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Rose' }, + { key: 'Background', value: 'Steel' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '35' }, + { key: 'Smile', value: '31' }, + { key: 'Hug', value: '45' }, + { key: 'Laugh', value: '16' }, + { key: 'Heart', value: '29' }, + { key: 'Warm', value: '53' }, + { key: 'Good', value: '34' }, + { key: 'Thanks', value: '48' }, + { key: 'Share', value: '48' }, + { key: 'Brave', value: '38' }, + { key: 'Grow', value: '8' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'E1cBh6oyu8aMcsFGHqkXUYxamhCV1LTnRGCEUXcmpwf9', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'E1cBh6oyu8aMcsFGHqkXUYxamhCV1LTnRGCEUXcmpwf9', + tokenId: null, + tokenStandard: 'NFT', + name: 'Himefu Moss 63', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/3eoPW3uLLh7n0zMPSAq_w1jV8qPaNnTU965QxkeCgI4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Moss' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '63' }, + { key: 'Smile', value: '50' }, + { key: 'Hug', value: '73' }, + { key: 'Laugh', value: '70' }, + { key: 'Heart', value: '76' }, + { key: 'Warm', value: '72' }, + { key: 'Good', value: '55' }, + { key: 'Thanks', value: '73' }, + { key: 'Share', value: '63' }, + { key: 'Brave', value: '60' }, + { key: 'Grow', value: '38' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '76unMz9mTcRmwxBdu5heBwgbwzSPRYZK9yYiTW4KEMi2', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '76unMz9mTcRmwxBdu5heBwgbwzSPRYZK9yYiTW4KEMi2', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hetoo Copper 43', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/HX2sDwqB7oXie9Rw2WEPjJmVV1Opi9cG_BwEHopeJRw', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '43' }, + { key: 'Smile', value: '63' }, + { key: 'Hug', value: '53' }, + { key: 'Laugh', value: '44' }, + { key: 'Heart', value: '25' }, + { key: 'Warm', value: '55' }, + { key: 'Good', value: '29' }, + { key: 'Thanks', value: '61' }, + { key: 'Share', value: '35' }, + { key: 'Brave', value: '44' }, + { key: 'Grow', value: '21' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'FDjX1GKUaBDkETH34Be3qZ5PxUMNDR3hoSjaaYAmrBwG', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'FDjX1GKUaBDkETH34Be3qZ5PxUMNDR3hoSjaaYAmrBwG', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nekuse Volcano 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/jL22y8u0_f3KLmhYB_JJ9QXGBXuLAoTPe6UtwkwJM-4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime Rose Gold' }, + { key: 'Background', value: 'Volcano' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '89' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '85' }, + { key: 'Laugh', value: '88' }, + { key: 'Heart', value: '88' }, + { key: 'Warm', value: '84' }, + { key: 'Good', value: '86' }, + { key: 'Thanks', value: '92' }, + { key: 'Share', value: '97' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '81' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'B9FQr2NUb9Z9eTRzH8DYBhd5z9pRwfYVEwazzVQn8gF4', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'B9FQr2NUb9Z9eTRzH8DYBhd5z9pRwfYVEwazzVQn8gF4', + tokenId: null, + tokenStandard: 'NFT', + name: 'Seashi Blush 62', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/vkW26LNNfKknVM7uoDY8EssRpJ-xAyeR7DIe2accOwI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '62' }, + { key: 'Smile', value: '54' }, + { key: 'Hug', value: '46' }, + { key: 'Laugh', value: '81' }, + { key: 'Heart', value: '57' }, + { key: 'Warm', value: '44' }, + { key: 'Good', value: '80' }, + { key: 'Thanks', value: '71' }, + { key: 'Share', value: '70' }, + { key: 'Brave', value: '21' }, + { key: 'Grow', value: '96' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'Goia9fJhcQwjixixzDA97TJ5QkiCCarE7HgRaQyzEFFN', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'Goia9fJhcQwjixixzDA97TJ5QkiCCarE7HgRaQyzEFFN', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hewasu Copper 12', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/SIuKlVtnFNrbLynJefY4COWrKIk6YXF1omAdRRiDXh8', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '12' }, + { key: 'Smile', value: '24' }, + { key: 'Hug', value: '1' }, + { key: 'Laugh', value: '2' }, + { key: 'Heart', value: '24' }, + { key: 'Warm', value: '15' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '30' }, + { key: 'Share', value: '16' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '6' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'HGaT3H4fGaDrj9YBzSALqqBDPTGuRmfsXT6RXSLTnMNX', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'HGaT3H4fGaDrj9YBzSALqqBDPTGuRmfsXT6RXSLTnMNX', + tokenId: null, + tokenStandard: 'NFT', + name: 'Ioyu Steel 64', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/k1AHX_ElRu4RkN_V_-eU_y8molElD1sqxB8mzeeI45E', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Steel' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '64' }, + { key: 'Smile', value: '61' }, + { key: 'Hug', value: '77' }, + { key: 'Laugh', value: '63' }, + { key: 'Heart', value: '61' }, + { key: 'Warm', value: '52' }, + { key: 'Good', value: '52' }, + { key: 'Thanks', value: '52' }, + { key: 'Share', value: '66' }, + { key: 'Brave', value: '86' }, + { key: 'Grow', value: '70' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'F2UFnyDs6ywQBtQfijF98quBzFzkjkzNr9z7eMpFnKQn', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'F2UFnyDs6ywQBtQfijF98quBzFzkjkzNr9z7eMpFnKQn', + tokenId: null, + tokenStandard: 'NFT', + name: 'Sunoso Crimson 11', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreifr4pwqjuvx2mpj6rhvtdiglclm25gznjf7lpnz3ruyx77zycg2fa', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '11' }, + { key: 'Smile', value: '1' }, + { key: 'Hug', value: '11' }, + { key: 'Laugh', value: '31' }, + { key: 'Heart', value: '1' }, + { key: 'Warm', value: '15' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '3' }, + { key: 'Share', value: '8' }, + { key: 'Brave', value: '23' }, + { key: 'Grow', value: '16' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '6TXuzQWRK6nJkh93gpfk5dP57sKTd8rNZAMpP4ng3rHj', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '6TXuzQWRK6nJkh93gpfk5dP57sKTd8rNZAMpP4ng3rHj', + tokenId: null, + tokenStandard: 'NFT', + name: 'Amuha Crimson 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/GLeaJtBoChF7YDTSTpHhBzM8TJDlDUop9teSfDMbd4k', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '41' }, + { key: 'Smile', value: '48' }, + { key: 'Hug', value: '43' }, + { key: 'Laugh', value: '58' }, + { key: 'Heart', value: '58' }, + { key: 'Warm', value: '31' }, + { key: 'Good', value: '48' }, + { key: 'Thanks', value: '60' }, + { key: 'Share', value: '45' }, + { key: 'Brave', value: '12' }, + { key: 'Grow', value: '7' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '6wbXdTidWfxjsDTrdXo9LyhWBLk4zPhi2pSsu93gj48U', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '6wbXdTidWfxjsDTrdXo9LyhWBLk4zPhi2pSsu93gj48U', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hakaya Stage 95', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Q-xsuhCPw5Js8UTDCYr7qu5jtSAL-Nwrf99N11hhPi4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Butter Taupe Rose' }, + { key: 'Background', value: 'Stage' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '95' }, + { key: 'Smile', value: '91' }, + { key: 'Hug', value: '96' }, + { key: 'Laugh', value: '93' }, + { key: 'Heart', value: '93' }, + { key: 'Warm', value: '95' }, + { key: 'Good', value: '100' }, + { key: 'Thanks', value: '94' }, + { key: 'Share', value: '91' }, + { key: 'Brave', value: '97' }, + { key: 'Grow', value: '100' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'AnHfaZXNv1zztXkAsKqDyQew77TBaq8EkRcNPS8TjqYK', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'AnHfaZXNv1zztXkAsKqDyQew77TBaq8EkRcNPS8TjqYK', + tokenId: null, + tokenStandard: 'NFT', + name: 'Shihitsu Azure 84', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/EJUSKNEz23WE_yqaq4NQ-5GQTjmShTic0DgeJ1mP8Tg', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '84' }, + { key: 'Smile', value: '82' }, + { key: 'Hug', value: '84' }, + { key: 'Laugh', value: '79' }, + { key: 'Heart', value: '74' }, + { key: 'Warm', value: '77' }, + { key: 'Good', value: '94' }, + { key: 'Thanks', value: '78' }, + { key: 'Share', value: '79' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '93' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GoLgRrRMrBwNSQuX9GMnw9LNZrnLc457TaE3kcvhSGdC', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GoLgRrRMrBwNSQuX9GMnw9LNZrnLc457TaE3kcvhSGdC', + tokenId: null, + tokenStandard: 'NFT', + name: 'Otowa Moss 68', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/VwMEUAk6U4EaHEWZW22t6BtGrA-fChg90jljLgtcSvs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Moss' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '68' }, + { key: 'Smile', value: '54' }, + { key: 'Hug', value: '75' }, + { key: 'Laugh', value: '63' }, + { key: 'Heart', value: '88' }, + { key: 'Warm', value: '87' }, + { key: 'Good', value: '76' }, + { key: 'Thanks', value: '49' }, + { key: 'Share', value: '84' }, + { key: 'Brave', value: '88' }, + { key: 'Grow', value: '16' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '8xrLyqfaVsbCe3xSvrqJrLhAbLnGdtMAbVs2FP79ZtzU', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '8xrLyqfaVsbCe3xSvrqJrLhAbLnGdtMAbVs2FP79ZtzU', + tokenId: null, + tokenStandard: 'NFT', + name: 'Keyaho Slate 27', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/gNH7x8H7pKpBemac6V_KXahmhwxGm5QBsTfjzWt4k9k', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '27' }, + { key: 'Smile', value: '39' }, + { key: 'Hug', value: '46' }, + { key: 'Laugh', value: '14' }, + { key: 'Heart', value: '15' }, + { key: 'Warm', value: '19' }, + { key: 'Good', value: '19' }, + { key: 'Thanks', value: '47' }, + { key: 'Share', value: '34' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '36' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'JAFFsHva4Uo7GCCKbWeLFZs2uJgvDtPzqvJ2jRktDzx6', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'JAFFsHva4Uo7GCCKbWeLFZs2uJgvDtPzqvJ2jRktDzx6', + tokenId: null, + tokenStandard: 'NFT', + name: 'Watohi Atlantis 92', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/BZHCO7179d1OXzJm0d7JRYAjiHQoGVA1nOYFD0huRCg', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky Blaze Mint' }, + { key: 'Background', value: 'Atlantis' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '92' }, + { key: 'Smile', value: '100' }, + { key: 'Hug', value: '86' }, + { key: 'Laugh', value: '82' }, + { key: 'Heart', value: '98' }, + { key: 'Warm', value: '96' }, + { key: 'Good', value: '89' }, + { key: 'Thanks', value: '90' }, + { key: 'Share', value: '99' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '80' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '9CX1KT8wi4rvPFCU8WL8X2U8P6sxGavyAh9wpBrpSXvn', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '9CX1KT8wi4rvPFCU8WL8X2U8P6sxGavyAh9wpBrpSXvn', + tokenId: null, + tokenStandard: 'NFT', + name: 'Inui Black 99', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Q7RfboNqOGM44NwzrSgiPhbvCccHCLgttfmT5sk9PwA', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Black' }, + { key: 'Rarity', value: 'Legendary' }, + { key: 'Rating', value: '99' }, + { key: 'Smile', value: '93' }, + { key: 'Hug', value: '99' }, + { key: 'Laugh', value: '91' }, + { key: 'Heart', value: '97' }, + { key: 'Warm', value: '95' }, + { key: 'Good', value: '96' }, + { key: 'Thanks', value: '97' }, + { key: 'Share', value: '97' }, + { key: 'Brave', value: '94' }, + { key: 'Grow', value: '94' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'FzBbVaUcUd5tkJTBU1WDMZRafQcZA7ygFpSVoJuc4PLj', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'FzBbVaUcUd5tkJTBU1WDMZRafQcZA7ygFpSVoJuc4PLj', + tokenId: null, + tokenStandard: 'NFT', + name: 'Renhi Copper 72', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/E5zaT2ZoI34q8VAzi5znRVWVFB7UZ_ShisuolmfAoaE', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '72' }, + { key: 'Smile', value: '76' }, + { key: 'Hug', value: '53' }, + { key: 'Laugh', value: '57' }, + { key: 'Heart', value: '85' }, + { key: 'Warm', value: '87' }, + { key: 'Good', value: '80' }, + { key: 'Thanks', value: '58' }, + { key: 'Share', value: '87' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '37' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '23vXxoc1EDL46NtBfdpZ7vPwTWN6eYTXgFAG9JN2jo1U', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '23vXxoc1EDL46NtBfdpZ7vPwTWN6eYTXgFAG9JN2jo1U', + tokenId: null, + tokenStandard: 'NFT', + name: 'Muwani Maize 34', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreifmsz5mfrepbl37autwckjwzvehxdmnunrifm7l2xhaippfght2hy', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Rose' }, + { key: 'Background', value: 'Maize' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '34' }, + { key: 'Smile', value: '43' }, + { key: 'Hug', value: '22' }, + { key: 'Laugh', value: '24' }, + { key: 'Heart', value: '35' }, + { key: 'Warm', value: '26' }, + { key: 'Good', value: '30' }, + { key: 'Thanks', value: '46' }, + { key: 'Share', value: '14' }, + { key: 'Brave', value: '33' }, + { key: 'Grow', value: '67' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '3LQXhQ7hCjyGt5BP2ugJLeJwc4wSY7BhL8kN43DenjBm', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '3LQXhQ7hCjyGt5BP2ugJLeJwc4wSY7BhL8kN43DenjBm', + tokenId: null, + tokenStandard: 'NFT', + name: 'Moriro Maize 39', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreig2xneuwtbhrncsv7f5isltw2rzfdbny2wrv3zpq2ayur3ckhjwyi', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Violet' }, + { key: 'Background', value: 'Maize' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '39' }, + { key: 'Smile', value: '20' }, + { key: 'Hug', value: '27' }, + { key: 'Laugh', value: '59' }, + { key: 'Heart', value: '59' }, + { key: 'Warm', value: '25' }, + { key: 'Good', value: '28' }, + { key: 'Thanks', value: '34' }, + { key: 'Share', value: '44' }, + { key: 'Brave', value: '75' }, + { key: 'Grow', value: '19' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'DvsAFqxLgQPv5U4A7WezLcAtPQG4dMQPw7N3fEkdzuc3', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'DvsAFqxLgQPv5U4A7WezLcAtPQG4dMQPw7N3fEkdzuc3', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kifuwa Copper 81', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/JfWa1IF1jcMwB6dSRcJOkQXyBGCGQr2uERMqt0c0SGo', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '81' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '83' }, + { key: 'Laugh', value: '77' }, + { key: 'Heart', value: '74' }, + { key: 'Warm', value: '91' }, + { key: 'Good', value: '79' }, + { key: 'Thanks', value: '84' }, + { key: 'Share', value: '79' }, + { key: 'Brave', value: '98' }, + { key: 'Grow', value: '56' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2V6tUoD1vJZXpNX6YC1j3gGRuUFANJDuaJAV3M7M7Qtm', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '2V6tUoD1vJZXpNX6YC1j3gGRuUFANJDuaJAV3M7M7Qtm', + tokenId: null, + tokenStandard: 'NFT', + name: 'Namiku Crimson 79', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/ybG1mkP9DIFRLEAaiu8RU6zYZa_0ADNocZAgDXwVTOo', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '79' }, + { key: 'Smile', value: '86' }, + { key: 'Hug', value: '77' }, + { key: 'Laugh', value: '82' }, + { key: 'Heart', value: '82' }, + { key: 'Warm', value: '72' }, + { key: 'Good', value: '82' }, + { key: 'Thanks', value: '81' }, + { key: 'Share', value: '88' }, + { key: 'Brave', value: '80' }, + { key: 'Grow', value: '60' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '66ePeHXTVWjdpHbf9RirdmpgUJHJ9HumiZeH6vTstpxj', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '66ePeHXTVWjdpHbf9RirdmpgUJHJ9HumiZeH6vTstpxj', + tokenId: null, + tokenStandard: 'NFT', + name: 'Heriho Blush 59', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/7Lkid1s0zRIOk_qugby0w0-s5McUZktp6p0R678_qD4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '59' }, + { key: 'Smile', value: '64' }, + { key: 'Hug', value: '44' }, + { key: 'Laugh', value: '73' }, + { key: 'Heart', value: '47' }, + { key: 'Warm', value: '46' }, + { key: 'Good', value: '57' }, + { key: 'Thanks', value: '73' }, + { key: 'Share', value: '69' }, + { key: 'Brave', value: '54' }, + { key: 'Grow', value: '63' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '8XgHPUSoBsh2MMjthCko8Dp11mji7oGhZ7FbfZre7K2Y', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '8XgHPUSoBsh2MMjthCko8Dp11mji7oGhZ7FbfZre7K2Y', + tokenId: null, + tokenStandard: 'NFT', + name: 'Emishi Cavern 93', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreif6p2wmov37vfmuynq6m2nibf36tf5c3wrg7o4jr7zmoq77oaue7a', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Butter Blaze Sky' }, + { key: 'Background', value: 'Cavern' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '93' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '92' }, + { key: 'Laugh', value: '100' }, + { key: 'Heart', value: '83' }, + { key: 'Warm', value: '100' }, + { key: 'Good', value: '100' }, + { key: 'Thanks', value: '95' }, + { key: 'Share', value: '100' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '71' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'A5PYKSjz55k7C5pia6vpSFjZbD4Ea8ZV2KYCY6PMfpKE', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'A5PYKSjz55k7C5pia6vpSFjZbD4Ea8ZV2KYCY6PMfpKE', + tokenId: null, + tokenStandard: 'NFT', + name: 'Tehise Umber 69', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/FaOcKsrOodVH8r2ys1GEvdlGKTAek2N13-Wi5MdrV28', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '69' }, + { key: 'Smile', value: '80' }, + { key: 'Hug', value: '82' }, + { key: 'Laugh', value: '71' }, + { key: 'Heart', value: '71' }, + { key: 'Warm', value: '83' }, + { key: 'Good', value: '68' }, + { key: 'Thanks', value: '53' }, + { key: 'Share', value: '80' }, + { key: 'Brave', value: '63' }, + { key: 'Grow', value: '39' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '7vYE2auoFeNZ4i3jZ1dJ17N3iJUd6tqSc8NT3mx2Jvk2', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '7vYE2auoFeNZ4i3jZ1dJ17N3iJUd6tqSc8NT3mx2Jvk2', + tokenId: null, + tokenStandard: 'NFT', + name: 'Mokise Azure 72', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/k8TkNfVdxmTH1Gc3yYnRL8cLL3t1jUAU2Zp42UTuH7k', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Coral' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '72' }, + { key: 'Smile', value: '90' }, + { key: 'Hug', value: '88' }, + { key: 'Laugh', value: '70' }, + { key: 'Heart', value: '61' }, + { key: 'Warm', value: '74' }, + { key: 'Good', value: '89' }, + { key: 'Thanks', value: '59' }, + { key: 'Share', value: '57' }, + { key: 'Brave', value: '92' }, + { key: 'Grow', value: '40' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'sVew7tk6GGRcTXGvy5tmZnAHaF3HWgTSoNDuenTKKDS', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'sVew7tk6GGRcTXGvy5tmZnAHaF3HWgTSoNDuenTKKDS', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rehemu Umber 68', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/YiJbXaBzCIXNwbtwYbCDLp76V02YzXiBocjfGABGcrE', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '68' }, + { key: 'Smile', value: '86' }, + { key: 'Hug', value: '69' }, + { key: 'Laugh', value: '64' }, + { key: 'Heart', value: '72' }, + { key: 'Warm', value: '61' }, + { key: 'Good', value: '58' }, + { key: 'Thanks', value: '66' }, + { key: 'Share', value: '84' }, + { key: 'Brave', value: '27' }, + { key: 'Grow', value: '93' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5JZ3JySwmA8hYwKvtyRJHV7Bx7hVb7X3xz4jwcfJvoKE', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5JZ3JySwmA8hYwKvtyRJHV7Bx7hVb7X3xz4jwcfJvoKE', + tokenId: null, + tokenStandard: 'NFT', + name: 'Shihawo Maize 10', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/_pCz0o6WCPeFlx9cPkJCKPRczc4jAmns7HgBjlHwOAs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Maize' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '10' }, + { key: 'Smile', value: '15' }, + { key: 'Hug', value: '1' }, + { key: 'Laugh', value: '14' }, + { key: 'Heart', value: '7' }, + { key: 'Warm', value: '12' }, + { key: 'Good', value: '2' }, + { key: 'Thanks', value: '14' }, + { key: 'Share', value: '18' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '16' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '9dHS9jmB75yZhPevY7rpdVGCakLMREzvW87kvzjFPMZC', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '9dHS9jmB75yZhPevY7rpdVGCakLMREzvW87kvzjFPMZC', + tokenId: null, + tokenStandard: 'NFT', + name: 'Muchinu Copper 17', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/RaNQBwiovSKFLEAa4R12caIMluHR9i_nCMlbkZwZafo', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '17' }, + { key: 'Smile', value: '6' }, + { key: 'Hug', value: '37' }, + { key: 'Laugh', value: '1' }, + { key: 'Heart', value: '21' }, + { key: 'Warm', value: '2' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '32' }, + { key: 'Share', value: '15' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '54' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BL4gnVWq6p91BTCgpae8Mz8TMPnz6mAdrwm7Mjmp7g8L', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BL4gnVWq6p91BTCgpae8Mz8TMPnz6mAdrwm7Mjmp7g8L', + tokenId: null, + tokenStandard: 'NFT', + name: 'Neroka Nostalgia 95', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/AirpCII2848n3dAjRxeb9jAq0cTin4a44OFu1k6aKo8', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime Sky Violet' }, + { key: 'Background', value: 'Nostalgia' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '95' }, + { key: 'Smile', value: '100' }, + { key: 'Hug', value: '100' }, + { key: 'Laugh', value: '95' }, + { key: 'Heart', value: '100' }, + { key: 'Warm', value: '100' }, + { key: 'Good', value: '93' }, + { key: 'Thanks', value: '100' }, + { key: 'Share', value: '93' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '69' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '3XzVvHQ4nddxeHidrVUDHGjk7qmmGQmgWYrK1evZL6SV', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '3XzVvHQ4nddxeHidrVUDHGjk7qmmGQmgWYrK1evZL6SV', + tokenId: null, + tokenStandard: 'NFT', + name: 'Esun Moss 47', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreiddoosg4przrxud5ztald37gabo7a4hqfelt4d7bh65jji42rsjvm', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Moss' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '47' }, + { key: 'Smile', value: '41' }, + { key: 'Hug', value: '66' }, + { key: 'Laugh', value: '45' }, + { key: 'Heart', value: '61' }, + { key: 'Warm', value: '51' }, + { key: 'Good', value: '53' }, + { key: 'Thanks', value: '29' }, + { key: 'Share', value: '39' }, + { key: 'Brave', value: '49' }, + { key: 'Grow', value: '36' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GVbaA4GqqceibdNjPaSsVccsYJtKk7ZPZD4ET9UZgpV4', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GVbaA4GqqceibdNjPaSsVccsYJtKk7ZPZD4ET9UZgpV4', + tokenId: null, + tokenStandard: 'NFT', + name: 'Rokane Red 98', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Vcv9OmO7rH_7hhqPSe037jFUnCwYCUdVRDpl2LI6pDw', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Red' }, + { key: 'Rarity', value: 'Legendary' }, + { key: 'Rating', value: '98' }, + { key: 'Smile', value: '98' }, + { key: 'Hug', value: '92' }, + { key: 'Laugh', value: '98' }, + { key: 'Heart', value: '91' }, + { key: 'Warm', value: '93' }, + { key: 'Good', value: '91' }, + { key: 'Thanks', value: '100' }, + { key: 'Share', value: '98' }, + { key: 'Brave', value: '96' }, + { key: 'Grow', value: '90' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '6SaWmPz9KUj2ahSVSc6gkeED4jSEGHYL6VsbB89uYre', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '6SaWmPz9KUj2ahSVSc6gkeED4jSEGHYL6VsbB89uYre', + tokenId: null, + tokenStandard: 'NFT', + name: 'Iheya Artic 87', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/BcOXu57leXYNFOrmDDyQqyXngaLze8eNHKELmDyUaYs', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe Violet Mint' }, + { key: 'Background', value: 'Artic' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '87' }, + { key: 'Smile', value: '87' }, + { key: 'Hug', value: '97' }, + { key: 'Laugh', value: '89' }, + { key: 'Heart', value: '84' }, + { key: 'Warm', value: '90' }, + { key: 'Good', value: '77' }, + { key: 'Thanks', value: '85' }, + { key: 'Share', value: '86' }, + { key: 'Brave', value: '95' }, + { key: 'Grow', value: '80' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2JpbZQRbKQypRj8aMCcRGNsHs1Af9ei9vJ8QYeLc5VZZ', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '2JpbZQRbKQypRj8aMCcRGNsHs1Af9ei9vJ8QYeLc5VZZ', + tokenId: null, + tokenStandard: 'NFT', + name: 'Onono Fantasy 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/6_42us2fNsf0b8NfP936MSt-yD2ZcTJGeVH4Oma7z6s', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Rose Lime Sky' }, + { key: 'Background', value: 'Fantasy' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '86' }, + { key: 'Smile', value: '81' }, + { key: 'Hug', value: '82' }, + { key: 'Laugh', value: '90' }, + { key: 'Heart', value: '94' }, + { key: 'Warm', value: '96' }, + { key: 'Good', value: '81' }, + { key: 'Thanks', value: '95' }, + { key: 'Share', value: '92' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '49' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '7ZBAFmT9Tq9ftw6PBQQfb9Ks3FR9Pd1QZZTVvCkvBbEZ', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '7ZBAFmT9Tq9ftw6PBQQfb9Ks3FR9Pd1QZZTVvCkvBbEZ', + tokenId: null, + tokenStandard: 'NFT', + name: 'Minuna Crimson 35', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreibpcafvzlmtrsgd6idgnjut6wfqpf7cnx5r4bhalpxikmiedwtkoa', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Tangerine' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '35' }, + { key: 'Smile', value: '31' }, + { key: 'Hug', value: '38' }, + { key: 'Laugh', value: '20' }, + { key: 'Heart', value: '53' }, + { key: 'Warm', value: '20' }, + { key: 'Good', value: '52' }, + { key: 'Thanks', value: '28' }, + { key: 'Share', value: '52' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '55' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'CmQrWkn7BMMh6oSXvQaavRsYNNED7odJNdWApPv8cpJs', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'CmQrWkn7BMMh6oSXvQaavRsYNNED7odJNdWApPv8cpJs', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kukime Lilac 79', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/zaMpFeeG8tXUU0r-VwaDga7s7SVszNaIDuK19N1sS-M', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Lilac' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '79' }, + { key: 'Smile', value: '82' }, + { key: 'Hug', value: '73' }, + { key: 'Laugh', value: '75' }, + { key: 'Heart', value: '74' }, + { key: 'Warm', value: '71' }, + { key: 'Good', value: '76' }, + { key: 'Thanks', value: '85' }, + { key: 'Share', value: '74' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '80' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'HqR3AeQe8DdEmrwx9WDHgFKtFk14vsyZiehaw7jYqCN4', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'HqR3AeQe8DdEmrwx9WDHgFKtFk14vsyZiehaw7jYqCN4', + tokenId: null, + tokenStandard: 'NFT', + name: 'Hosake Copper 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreifm5zxmd4fx6fycr2skxan565nzk5i73y5urcqxbuuyjsq3i5ljly', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Blaze' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '30' }, + { key: 'Smile', value: '46' }, + { key: 'Hug', value: '39' }, + { key: 'Laugh', value: '13' }, + { key: 'Heart', value: '27' }, + { key: 'Warm', value: '21' }, + { key: 'Good', value: '26' }, + { key: 'Thanks', value: '10' }, + { key: 'Share', value: '26' }, + { key: 'Brave', value: '64' }, + { key: 'Grow', value: '28' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BDnLCAcai1j8oRXWXBGAfMCDsFSMZLZrT1zDxBP2A65K', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BDnLCAcai1j8oRXWXBGAfMCDsFSMZLZrT1zDxBP2A65K', + tokenId: null, + tokenStandard: 'NFT', + name: 'Mumani Crimson 17', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/_qiLOsq812UBzz6ZVJtLH5avgdxNpl494yIoNbIED8M', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Gold' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '17' }, + { key: 'Smile', value: '17' }, + { key: 'Hug', value: '17' }, + { key: 'Laugh', value: '18' }, + { key: 'Heart', value: '1' }, + { key: 'Warm', value: '33' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '8' }, + { key: 'Share', value: '23' }, + { key: 'Brave', value: '46' }, + { key: 'Grow', value: '6' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'J2V4EVhC7J1zm5gGPYo5WqMGsJAPvSon2kjDBaYZ5CEv', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'J2V4EVhC7J1zm5gGPYo5WqMGsJAPvSon2kjDBaYZ5CEv', + tokenId: null, + tokenStandard: 'NFT', + name: 'Inaro Slate 60', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/l1Vpj5W5xEmERyos7uuCWRj1oe7kUoVQslbuouz0Yzg', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Violet' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '60' }, + { key: 'Smile', value: '63' }, + { key: 'Hug', value: '48' }, + { key: 'Laugh', value: '46' }, + { key: 'Heart', value: '44' }, + { key: 'Warm', value: '76' }, + { key: 'Good', value: '77' }, + { key: 'Thanks', value: '45' }, + { key: 'Share', value: '47' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '54' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5CRJGB6hyjZ9JUXK1qu8btGuJH8U56q4tenKmU7noj6H', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5CRJGB6hyjZ9JUXK1qu8btGuJH8U56q4tenKmU7noj6H', + tokenId: null, + tokenStandard: 'NFT', + name: 'Sanaya Slate 74', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/P_R0j05mMT1HSD9zKZaTj_vlQVAO_3TohZgY321obTI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Slate' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '74' }, + { key: 'Smile', value: '82' }, + { key: 'Hug', value: '85' }, + { key: 'Laugh', value: '64' }, + { key: 'Heart', value: '60' }, + { key: 'Warm', value: '91' }, + { key: 'Good', value: '79' }, + { key: 'Thanks', value: '85' }, + { key: 'Share', value: '57' }, + { key: 'Brave', value: '67' }, + { key: 'Grow', value: '70' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'BqgsdJQemGCuy9e7dcxmHnBnGDTfuugs6Eaw13exyxAC', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'BqgsdJQemGCuy9e7dcxmHnBnGDTfuugs6Eaw13exyxAC', + tokenId: null, + tokenStandard: 'NFT', + name: 'Kimike Moss 64', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreidv72x37cbinkvabz6pubwof7euye46lc7bzao4azk63wiu4v7qjy', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Blaze' }, + { key: 'Background', value: 'Moss' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '64' }, + { key: 'Smile', value: '81' }, + { key: 'Hug', value: '47' }, + { key: 'Laugh', value: '50' }, + { key: 'Heart', value: '65' }, + { key: 'Warm', value: '68' }, + { key: 'Good', value: '81' }, + { key: 'Thanks', value: '45' }, + { key: 'Share', value: '55' }, + { key: 'Brave', value: '60' }, + { key: 'Grow', value: '88' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4iwQrtmh91iMBvAtiEzNHd1cv1QiajbT9dG919ACbFtm', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4iwQrtmh91iMBvAtiEzNHd1cv1QiajbT9dG919ACbFtm', + tokenId: null, + tokenStandard: 'NFT', + name: 'Ramure Umber 5', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/mFWJGX924Dy-FxbLsDslsT7pwuK8D9opTFhZi2HJUKE', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Umber' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '5' }, + { key: 'Smile', value: '1' }, + { key: 'Hug', value: '1' }, + { key: 'Laugh', value: '15' }, + { key: 'Heart', value: '4' }, + { key: 'Warm', value: '1' }, + { key: 'Good', value: '1' }, + { key: 'Thanks', value: '1' }, + { key: 'Share', value: '1' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '24' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5NWin7cHMBUtTjGv9D8fwZwyxvrH6T4uEdK18CuYrjGQ', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5NWin7cHMBUtTjGv9D8fwZwyxvrH6T4uEdK18CuYrjGQ', + tokenId: null, + tokenStandard: 'NFT', + name: 'Toruha Blush 25', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Qe_StXHAExNaOk-t-oRUqQ9KoibTWrEhXsgeQjz5ovk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '25' }, + { key: 'Smile', value: '30' }, + { key: 'Hug', value: '14' }, + { key: 'Laugh', value: '40' }, + { key: 'Heart', value: '25' }, + { key: 'Warm', value: '16' }, + { key: 'Good', value: '32' }, + { key: 'Thanks', value: '11' }, + { key: 'Share', value: '22' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '59' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'A2kRbc9PErBhfj5bRW47ji8cEJF1Fm23xo9qTJzUPuAi', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'A2kRbc9PErBhfj5bRW47ji8cEJF1Fm23xo9qTJzUPuAi', + tokenId: null, + tokenStandard: 'NFT', + name: 'Yanuhi Lilac 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://w3s.link/ipfs/bafkreiehytzikifiilewin6rgitkfifyrle2tr6donbad4jnkjsgvlzscm', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Lilac' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '30' }, + { key: 'Smile', value: '47' }, + { key: 'Hug', value: '10' }, + { key: 'Laugh', value: '41' }, + { key: 'Heart', value: '32' }, + { key: 'Warm', value: '23' }, + { key: 'Good', value: '29' }, + { key: 'Thanks', value: '44' }, + { key: 'Share', value: '45' }, + { key: 'Brave', value: '1' }, + { key: 'Grow', value: '28' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GnSLjLgDpP1gPGnMsQQ9g1t5gWn1FiaYJRGsDH1jLvrb', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GnSLjLgDpP1gPGnMsQQ9g1t5gWn1FiaYJRGsDH1jLvrb', + tokenId: null, + tokenStandard: 'NFT', + name: 'Tefute Lilac 38', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/u52d29x9EnRVFzMnj8P5FSCVZqfKykylPPiL6WSTETI', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky' }, + { key: 'Background', value: 'Lilac' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '38' }, + { key: 'Smile', value: '36' }, + { key: 'Hug', value: '41' }, + { key: 'Laugh', value: '58' }, + { key: 'Heart', value: '24' }, + { key: 'Warm', value: '56' }, + { key: 'Good', value: '58' }, + { key: 'Thanks', value: '22' }, + { key: 'Share', value: '18' }, + { key: 'Brave', value: '47' }, + { key: 'Grow', value: '20' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4C3yLSBWzE8BoEcbYCrDYZG6DPPa2JiUJDaK7DuP5waw', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4C3yLSBWzE8BoEcbYCrDYZG6DPPa2JiUJDaK7DuP5waw', + tokenId: null, + tokenStandard: 'NFT', + name: 'Waria Fantasy 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/VXjz_9IOmrxFpGATG2YBe4UblSo7rEYedBhv89tAc0Y', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Mint Stone Gold' }, + { key: 'Background', value: 'Fantasy' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '86' }, + { key: 'Smile', value: '91' }, + { key: 'Hug', value: '79' }, + { key: 'Laugh', value: '90' }, + { key: 'Heart', value: '85' }, + { key: 'Warm', value: '82' }, + { key: 'Good', value: '88' }, + { key: 'Thanks', value: '81' }, + { key: 'Share', value: '86' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '78' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '4sBK1uNN4H2BdjXgYnnU9UrVLR5N5gHfcaTwpMp4ZchR', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '4sBK1uNN4H2BdjXgYnnU9UrVLR5N5gHfcaTwpMp4ZchR', + tokenId: null, + tokenStandard: 'NFT', + name: 'Yushini Crimson 58', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/kN2l0ydJRhKMRZRGBaBTP6YeV_tcRWPNtV-UNLlqNZ4', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '58' }, + { key: 'Smile', value: '39' }, + { key: 'Hug', value: '47' }, + { key: 'Laugh', value: '44' }, + { key: 'Heart', value: '41' }, + { key: 'Warm', value: '76' }, + { key: 'Good', value: '65' }, + { key: 'Thanks', value: '56' }, + { key: 'Share', value: '73' }, + { key: 'Brave', value: '54' }, + { key: 'Grow', value: '85' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'HoqygdHmhszACPdKz49MWCiM9gpRewsb2BxN7WFbVZ8g', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'HoqygdHmhszACPdKz49MWCiM9gpRewsb2BxN7WFbVZ8g', + tokenId: null, + tokenStandard: 'NFT', + name: 'Temimi Volcano 91', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/GKGnHgLTaEWfhoWO7ZJsC_8a7xaSiEO9eSC3QmBnRUg', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Taupe Tangerine Sky' }, + { key: 'Background', value: 'Volcano' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '91' }, + { key: 'Smile', value: '89' }, + { key: 'Hug', value: '91' }, + { key: 'Laugh', value: '84' }, + { key: 'Heart', value: '98' }, + { key: 'Warm', value: '100' }, + { key: 'Good', value: '90' }, + { key: 'Thanks', value: '87' }, + { key: 'Share', value: '89' }, + { key: 'Brave', value: '95' }, + { key: 'Grow', value: '87' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'TzuLBLZQz34QuMGhBWfagW3CX7MRgbnX9TGvgMq7Er7', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'TzuLBLZQz34QuMGhBWfagW3CX7MRgbnX9TGvgMq7Er7', + tokenId: null, + tokenStandard: 'NFT', + name: 'Reose Azure 71', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/mMsD_yGDrzPsQn9Qlzvj_zOCAGvkLleTJbXdzavzg0g', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Azure' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '71' }, + { key: 'Smile', value: '90' }, + { key: 'Hug', value: '76' }, + { key: 'Laugh', value: '53' }, + { key: 'Heart', value: '74' }, + { key: 'Warm', value: '80' }, + { key: 'Good', value: '84' }, + { key: 'Thanks', value: '84' }, + { key: 'Share', value: '86' }, + { key: 'Brave', value: '80' }, + { key: 'Grow', value: '3' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '2gqJ1hBKNAz4Zp4N3jtNNyNMgPoiVELJ39dzNscAwrMS', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '2gqJ1hBKNAz4Zp4N3jtNNyNMgPoiVELJ39dzNscAwrMS', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nenesu Blush 20', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/J57aqt1TXeadJFdCmhaPPG9l2rMWdC4gPzt-28jAmcc', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Blush' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '20' }, + { key: 'Smile', value: '30' }, + { key: 'Hug', value: '16' }, + { key: 'Laugh', value: '7' }, + { key: 'Heart', value: '19' }, + { key: 'Warm', value: '18' }, + { key: 'Good', value: '16' }, + { key: 'Thanks', value: '24' }, + { key: 'Share', value: '36' }, + { key: 'Brave', value: '13' }, + { key: 'Grow', value: '21' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'AempPeKEMVyKoopMSLgTJ8uhgxXAQSeGKkN6Bxg59Sjv', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'AempPeKEMVyKoopMSLgTJ8uhgxXAQSeGKkN6Bxg59Sjv', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nuteki Crimson 18', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/YVk50ScKfjw_Ge7jzH7AvCBFY-asGWkiJiALH0l1WFU', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone' }, + { key: 'Background', value: 'Crimson' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '18' }, + { key: 'Smile', value: '2' }, + { key: 'Hug', value: '25' }, + { key: 'Laugh', value: '13' }, + { key: 'Heart', value: '15' }, + { key: 'Warm', value: '28' }, + { key: 'Good', value: '17' }, + { key: 'Thanks', value: '1' }, + { key: 'Share', value: '17' }, + { key: 'Brave', value: '47' }, + { key: 'Grow', value: '15' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'FJjbsmyDUiSWqnTScUVPJ7gtkgWLu8eLNE2ZSZQhWH4o', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'FJjbsmyDUiSWqnTScUVPJ7gtkgWLu8eLNE2ZSZQhWH4o', + tokenId: null, + tokenStandard: 'NFT', + name: 'Tooku Fantasy 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/TbZlusywCg4INdEwAv4Q-Fz00bZjyRGaqrwCFlw0uUc', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Sky Stone Lime' }, + { key: 'Background', value: 'Fantasy' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '89' }, + { key: 'Smile', value: '98' }, + { key: 'Hug', value: '89' }, + { key: 'Laugh', value: '97' }, + { key: 'Heart', value: '88' }, + { key: 'Warm', value: '89' }, + { key: 'Good', value: '99' }, + { key: 'Thanks', value: '81' }, + { key: 'Share', value: '87' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '62' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'GPCD4HbAgm4sYAEeChCTr6cvnQFqpCV1Hb4YqtUBFMLs', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'GPCD4HbAgm4sYAEeChCTr6cvnQFqpCV1Hb4YqtUBFMLs', + tokenId: null, + tokenStandard: 'NFT', + name: 'Miuse Copper 70', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/Fwub1TdOGg0-tqS6zWs43SVdUj7ppT3eboXXTUExuVQ', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Lime' }, + { key: 'Background', value: 'Copper' }, + { key: 'Rarity', value: 'Common' }, + { key: 'Rating', value: '70' }, + { key: 'Smile', value: '66' }, + { key: 'Hug', value: '89' }, + { key: 'Laugh', value: '61' }, + { key: 'Heart', value: '51' }, + { key: 'Warm', value: '63' }, + { key: 'Good', value: '53' }, + { key: 'Thanks', value: '76' }, + { key: 'Share', value: '67' }, + { key: 'Brave', value: '86' }, + { key: 'Grow', value: '88' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: '5f8JwHZdGVMzb3juDgpPT1hnYD18MofKv1sEjxghRDGH', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: '5f8JwHZdGVMzb3juDgpPT1hnYD18MofKv1sEjxghRDGH', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nechiwa Desert 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/vWTByzWVm_Gln-qyKU6Ajy3eWcZkdRs0BUZu6-hjrjE', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Violet Tangerine Butter' }, + { key: 'Background', value: 'Desert' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '89' }, + { key: 'Smile', value: '84' }, + { key: 'Hug', value: '80' }, + { key: 'Laugh', value: '95' }, + { key: 'Heart', value: '94' }, + { key: 'Warm', value: '84' }, + { key: 'Good', value: '82' }, + { key: 'Thanks', value: '95' }, + { key: 'Share', value: '95' }, + { key: 'Brave', value: '94' }, + { key: 'Grow', value: '87' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + tokenAddress: 'eUyEuzbhePsioreWqYsJ82KtLpgTDLwVkAvLxQqBTKV', + tokenId: null, + balance: 1, + acquiredAt: null, + isSpam: false, + nftToken: { + address: 'eUyEuzbhePsioreWqYsJ82KtLpgTDLwVkAvLxQqBTKV', + tokenId: null, + tokenStandard: 'NFT', + name: 'Nosuru Fantasy 90', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + imageUrl: + 'https://gateway.irys.xyz/31LAzQj901LQO_hBh4EHy0H739AuqPw2lmbRGzIzqlk', + mediaUrl: null, + externalUrl: 'https://www.monmonmon.art/', + attributes: [ + { key: 'Body', value: 'Stone Sky Tangerine' }, + { key: 'Background', value: 'Fantasy' }, + { key: 'Rarity', value: 'Rare' }, + { key: 'Rating', value: '90' }, + { key: 'Smile', value: '95' }, + { key: 'Hug', value: '98' }, + { key: 'Laugh', value: '88' }, + { key: 'Heart', value: '93' }, + { key: 'Warm', value: '92' }, + { key: 'Good', value: '93' }, + { key: 'Thanks', value: '93' }, + { key: 'Share', value: '81' }, + { key: 'Brave', value: '100' }, + { key: 'Grow', value: '67' }, + ], + tokenAccountAddress: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collectionName: 'MONMONMON HOLA', + collectionSymbol: 'hola_monmonmon', + collectionCount: 888659, + collectionImageUrl: null, + onchainCollectionAddress: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floorPrice: null, + lastSalePrice: null, + rarity: null, + }, + }, + ], +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseRaw.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseRaw.ts new file mode 100644 index 00000000..e0c78051 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/mocks/mockNftsListResponseRaw.ts @@ -0,0 +1,10073 @@ +import type { NftApiBalanceType, PaginatedResponse } from '../types'; + +export const MOCK_NFTS_LIST_RESPONSE_RAW: PaginatedResponse = + { + cursor: '78f58ef3ed72f146b24e2eebde142a2f', + error: null, + items: [ + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + token_id: null, + balance: 1, + acquired_at: null, + isSpam: true, + nft_token: { + address: '8d35QSkuPezKSgrj4hnQqyWscgp9aVNi4ARaccrk5DAK', + token_id: null, + token_standard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + media_url: null, + external_url: '', + attributes: [ + { + key: 'Sky', + value: 'Uncharted darkness', + }, + { + key: 'Accent', + value: 'Starship', + }, + ], + token_account_address: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collection_name: 'AB Open #02', + collection_symbol: 'ab_open_02', + collection_count: 1076387, + collection_image_url: null, + onchain_collection_address: + 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'FsKvd5n7mrMj1sB4mSMe1B3fBqXftE3hNJdFdT4WzZhr', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'FsKvd5n7mrMj1sB4mSMe1B3fBqXftE3hNJdFdT4WzZhr', + token_id: null, + token_standard: 'NFT', + name: 'AB Open #02', + description: 'Check jupmarket . com​ and receive your $1000', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmYV6boiCWMaXirg5v9S5ELoToLmfPkNY38sGBjKdTvs4o', + media_url: null, + external_url: '', + attributes: [ + { + key: 'Sky', + value: 'Uncharted darkness', + }, + { + key: 'Accent', + value: 'Starship', + }, + ], + token_account_address: null, + creators: [ + { + address: '48YA8rBqD8ptGtCws7676EsFLegzMQDytjEBiGEmzBr2', + share: 100, + verified: 0, + }, + ], + collection_name: 'AB Open #02', + collection_symbol: 'ab_open_02', + collection_count: 1076387, + collection_image_url: null, + onchain_collection_address: + 'Vti9abnrQhEa43rkATT1E8Qnt2rEE9uR5qjGY7i9Gqg', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: true, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'EbedVEfLHYQpmVW3ay757PPz5jCjfc42ofPqgp21zhA6', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'EbedVEfLHYQpmVW3ay757PPz5jCjfc42ofPqgp21zhA6', + token_id: null, + token_standard: 'NFT', + name: 'Ticket #11', + description: '', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmSfFas4yL7VB5c6XhSWUsLReuhjnECuTsGtukDLnDmQuh', + media_url: null, + external_url: 'https://jplegacy.example', + attributes: [ + { + key: 'Background', + value: 'Midnight Cityscape', + }, + { + key: 'Suit Color', + value: 'Neo Teal', + }, + { + key: 'Helmet Glow', + value: 'Pulsating Neon', + }, + { + key: 'Galaxy Light', + value: 'Aurora Blues', + }, + ], + token_account_address: null, + creators: [ + { + address: 'Aqig7DrpvaGMFoivYp7gCJLBXq6vMXj1c7p2rEcXdUh2', + share: 100, + verified: 0, + }, + ], + collection_name: 'Ticket #11', + collection_symbol: 'ticket_11', + collection_count: 169998, + collection_image_url: null, + onchain_collection_address: + '9wGQpooVgVRKhKzazRjRYNmaVwXDHAXFNKaXJNaKSCek', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'HMriMWAZyExwmJ28588fHstUuSBgLtD5tfeZJZTX2NxY', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'HMriMWAZyExwmJ28588fHstUuSBgLtD5tfeZJZTX2NxY', + token_id: null, + token_standard: 'NFT', + name: 'Ticket #11', + description: '', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmSfFas4yL7VB5c6XhSWUsLReuhjnECuTsGtukDLnDmQuh', + media_url: null, + external_url: 'https://jplegacy.example', + attributes: [ + { + key: 'Background', + value: 'Midnight Cityscape', + }, + { + key: 'Suit Color', + value: 'Neo Teal', + }, + { + key: 'Helmet Glow', + value: 'Pulsating Neon', + }, + { + key: 'Galaxy Light', + value: 'Aurora Blues', + }, + ], + token_account_address: null, + creators: [ + { + address: 'Aqig7DrpvaGMFoivYp7gCJLBXq6vMXj1c7p2rEcXdUh2', + share: 100, + verified: 0, + }, + ], + collection_name: 'Ticket #11', + collection_symbol: 'ticket_11', + collection_count: 169998, + collection_image_url: null, + onchain_collection_address: + '9wGQpooVgVRKhKzazRjRYNmaVwXDHAXFNKaXJNaKSCek', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2Q9qk8eSMnFAaLFqedSzYgbYa4oNcxBC99joYFeUm4ur', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2Q9qk8eSMnFAaLFqedSzYgbYa4oNcxBC99joYFeUm4ur', + token_id: null, + token_standard: 'NFT', + name: '#7', + description: 'Unlock 1000 $ТRUMР at ТRUMPСHEST​.​СOM', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmZ1mKs7hZEgK7f4j4r2YG7VwRsvNc2QP8Z6c2Ucp5Tja2', + media_url: null, + external_url: '', + attributes: [ + { + key: '', + value: '', + }, + { + key: '', + value: '', + }, + { + key: '', + value: '', + }, + ], + token_account_address: null, + creators: [ + { + address: '7kCL2HxbcvoVvJaF7awjznm36CkPbz11mKk9wn6oySQQ', + share: 100, + verified: 0, + }, + ], + collection_name: '$ТRUMР #01280', + collection_symbol: 'rum_01280', + collection_count: 2387471, + collection_image_url: null, + onchain_collection_address: + '6Yw2PXmCTsXqdX2HxHsBwSMEaag7nSjQjWpCdAu5sbj2', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: true, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '7BkNG96HhRgAL3hqSUybyeydoVHpD9zFaaNAVbUScXJK', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '7BkNG96HhRgAL3hqSUybyeydoVHpD9zFaaNAVbUScXJK', + token_id: null, + token_standard: 'NFT', + name: '#7', + description: 'Unlock 1000 $ТRUMР at ТRUMPСHEST​.​СOM', + metadata: null, + image_url: + 'https://ipfs.io/ipfs/QmZ1mKs7hZEgK7f4j4r2YG7VwRsvNc2QP8Z6c2Ucp5Tja2', + media_url: null, + external_url: '', + attributes: [ + { + key: '', + value: '', + }, + { + key: '', + value: '', + }, + { + key: '', + value: '', + }, + ], + token_account_address: null, + creators: [ + { + address: '7kCL2HxbcvoVvJaF7awjznm36CkPbz11mKk9wn6oySQQ', + share: 100, + verified: 0, + }, + ], + collection_name: '$ТRUMР #01280', + collection_symbol: 'rum_01280', + collection_count: 2387471, + collection_image_url: null, + onchain_collection_address: + '6Yw2PXmCTsXqdX2HxHsBwSMEaag7nSjQjWpCdAu5sbj2', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: true, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '98yhmwEkWNDuTaQiUTSbCuAot4rH9vzxkFrDb2EEL6Cm', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '98yhmwEkWNDuTaQiUTSbCuAot4rH9vzxkFrDb2EEL6Cm', + token_id: null, + token_standard: 'NFT', + name: 'Spaceship #11265', + description: + 'The Coalition uncovered a wide variety of issues when trying to expand their influence in the new galaxy, learning a great deal from their mistakes in the process. When the opportunity to construct new exploration vessels finally arrived, the engineers knew what the people of the Coalition needed. Powerful engines, pinpoint sensory equipment, cargo and fuel capacity upgrades would all ensure success even in particularly complicated scenarios.', + metadata: null, + image_url: + 'https://s3.projectnebula.com/collection/pn-ship/ship-11265.jpg', + media_url: null, + external_url: 'https://play.projectnebula.com/ship/11265', + attributes: [ + { + key: 'Ship class', + value: 'Exemplar', + }, + { + key: 'Ship type', + value: 'Exploration vessel', + }, + { + key: 'Tier', + value: 'I', + }, + { + key: 'Generation', + value: 'GEN-2', + }, + { + key: 'Background', + value: 'Paragon', + }, + { + key: 'Coating', + value: 'Famous Fox Federation', + }, + ], + token_account_address: null, + creators: [ + { + address: 'SPACLeJShFiF9YHgj7PHgSk93ZvkFGE7hEwK63G2Dq9', + share: 100, + verified: 0, + }, + ], + collection_name: 'Project Nebula Spaceships', + collection_symbol: 'project_nebula_spaceship', + collection_count: 12061, + collection_image_url: null, + onchain_collection_address: + 'GNA7ev6TkKN2iCQd8ePGEDb36XvYr7hpKm4nmejtPCoZ', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2bizwWM2QAtXwxcAoYuVjJ1W65rWQKcjujBQdYHVyr4m', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2bizwWM2QAtXwxcAoYuVjJ1W65rWQKcjujBQdYHVyr4m', + token_id: null, + token_standard: 'NFT', + name: 'For You 5000Jup 5000Jup.com', + description: + 'Visit the domain shown in the picture and claim your exclusive voucher 5000jup.com', + metadata: null, + image_url: 'https://img.hi-hi.vip/json/img/5000jup1.png', + media_url: null, + external_url: 'https://5000jup.com', + attributes: [ + { + key: 'Website', + value: 'https://5000jup.com', + }, + { + key: 'Verified', + value: 'True', + }, + { + key: 'Amount', + value: '5,000+ JUP ($5000+)', + }, + { + key: 'Time Left', + value: '30 minutes!', + }, + ], + token_account_address: null, + creators: [ + { + address: 'Gx31E1GUzEeqjCHPtyLkNQHCqwi6REpyp66Hi262vCtG', + share: 100, + verified: 1, + }, + ], + collection_name: 'For You 5000Jup', + collection_symbol: 'for_you_5000jup', + collection_count: 0, + collection_image_url: null, + onchain_collection_address: + 'hD6sEXnP9ihfeGKfRknKR5zZqE7upXJ7sJd4DZoM6WJ', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '7LrcVUyRqS1nSUvoBxoCy1iXEGYG6W9X7LRKsuai254K', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '7LrcVUyRqS1nSUvoBxoCy1iXEGYG6W9X7LRKsuai254K', + token_id: null, + token_standard: 'NFT', + name: 'Golden Shorts Bingo Cards', + description: + 'Official Bingo Cards & Mint Pass of the Golden Shorts Rascal Club. #77903', + metadata: null, + image_url: + 'https://bafybeiarry26winniyzce7gzzlidpkgs2xbcwevv7jmq4ifnqvlygungm4.ipfs.w3s.link/3511.png', + media_url: null, + external_url: '', + attributes: [ + { + key: 'Background', + value: 'rascals_whitelist_bingo', + }, + { + key: 'B', + value: '8b$100', + }, + { + key: 'I', + value: '8a$100', + }, + { + key: 'N', + value: 'n32453844', + }, + { + key: 'G', + value: 'g54555351', + }, + { + key: 'O', + value: 'o626371', + }, + ], + token_account_address: null, + creators: [ + { + address: 'A2b2NA5QADSUQkjVqTmz2HzsRyYECgKxcrT2x4owxHoZ', + share: 100, + verified: 0, + }, + ], + collection_name: 'GOLDEN RASCALS BINGO WL EDITION', + collection_symbol: 'bingo', + collection_count: 62261, + collection_image_url: null, + onchain_collection_address: + 'CvsKkZM6UAvCJBF2SGLfR8di52ZhYscg9zBNzCfkB19H', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'DyBtina3CQzQXeQz9HxSWZj5ku6ta7DrHhwnowDygAFA', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'DyBtina3CQzQXeQz9HxSWZj5ku6ta7DrHhwnowDygAFA', + token_id: null, + token_standard: 'NFT', + name: 'Choizzy Pass #6', + description: + 'Choizzy Passes are NFTs that grant whitelist access to the Choizzy Main mint. They also serve as a reward for our fantastic early adopters. Hold tight!', + metadata: null, + image_url: + 'https://bafkreigv5rks54dgimzi764io7tfavlwlwav7qcicuokhox2ve4yynj54a.ipfs.nftstorage.link/', + media_url: null, + external_url: 'https://choizzy.io', + attributes: [ + { + key: 'Hoodie Color', + value: 'Green', + }, + { + key: 'Eyes', + value: 'Regular', + }, + ], + token_account_address: null, + creators: [ + { + address: 'DK2iYsdgpYoZB9EPJrffz2XyuLBPkJjdUjsrCyZqg4U1', + share: 0, + verified: 1, + }, + { + address: '3eanCCeoApEmJUhmKsfWHgjrBVedQRrPJzAZUstiEnMH', + share: 100, + verified: 0, + }, + ], + collection_name: 'Choizzy Pass', + collection_symbol: 'choizzy_pass', + collection_count: 1999, + collection_image_url: null, + onchain_collection_address: + '4GW29XZQTtk4iKttRdUF1MLEc6gRYhYH8BPd6jfLSGp4', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '69MSbRdmkfW5AiAHNGcufqmocVxgrss3Nq1QvqZKg6Pf', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '69MSbRdmkfW5AiAHNGcufqmocVxgrss3Nq1QvqZKg6Pf', + token_id: null, + token_standard: 'NFT', + name: 'Primes Airdrop Box', + description: + 'The Primes Airdrop Boxes is your chance on getting the $PRM Token before everyone else. Boxes can now be burnt on The Primes official website.', + metadata: null, + image_url: + 'https://shdw-drive.genesysgo.net/2MYtdX5Bwz68aVny1cHqRyujAJkNEzknQyF1SNav8dTh/1.png', + media_url: null, + external_url: 'https://theprimes.io', + attributes: [ + { + key: 'Revealed', + value: 'Pending', + }, + { + key: 'Claim At', + value: 'https://theprimes.io/burn2earn', + }, + ], + token_account_address: null, + creators: [ + { + address: 'pxmVowVaqXn23krwFxjzKC6UW4YjbYEQUqYC6q1Ta57', + share: 100, + verified: 0, + }, + ], + collection_name: 'Primes Airdrop Boxes', + collection_symbol: 'primes_airdrop_boxes', + collection_count: 69425, + collection_image_url: null, + onchain_collection_address: null, + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '7GEi7ytc31LPcyPtBd9WCdPm4P71W9iV2sUSJHW7BuN2', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '7GEi7ytc31LPcyPtBd9WCdPm4P71W9iV2sUSJHW7BuN2', + token_id: null, + token_standard: 'NFT', + name: 'Primes Airdrop Box', + description: + 'The Primes Airdrop Boxes is your chance on getting the $PRM Token before everyone else. Boxes can now be burnt on The Primes official website.', + metadata: null, + image_url: + 'https://shdw-drive.genesysgo.net/2MYtdX5Bwz68aVny1cHqRyujAJkNEzknQyF1SNav8dTh/1.png', + media_url: null, + external_url: 'https://theprimes.io', + attributes: [ + { + key: 'Revealed', + value: 'Pending', + }, + { + key: 'Claim At', + value: 'https://theprimes.io/burn2earn', + }, + ], + token_account_address: null, + creators: [ + { + address: 'pxmVowVaqXn23krwFxjzKC6UW4YjbYEQUqYC6q1Ta57', + share: 100, + verified: 0, + }, + ], + collection_name: 'Primes Airdrop Boxes', + collection_symbol: 'primes_airdrop_boxes', + collection_count: 69425, + collection_image_url: null, + onchain_collection_address: null, + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GgzC3fbvWsq83XLj1C7VK3q8Fy7HDbU15RuJCb4EfVMF', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GgzC3fbvWsq83XLj1C7VK3q8Fy7HDbU15RuJCb4EfVMF', + token_id: null, + token_standard: 'NFT', + name: 'Utamu Slate 65', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/1FsiYUkck9I058bkJtvgj93xFewLVJC6VhEqoMT7aQU', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '65', + }, + { + key: 'Smile', + value: '45', + }, + { + key: 'Hug', + value: '46', + }, + { + key: 'Laugh', + value: '72', + }, + { + key: 'Heart', + value: '85', + }, + { + key: 'Warm', + value: '69', + }, + { + key: 'Good', + value: '70', + }, + { + key: 'Thanks', + value: '64', + }, + { + key: 'Share', + value: '81', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '18', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'Gon7ttJSjAc9dkzayZzWwtsNKDf5mqz3gWYJs8VDDwHa', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'Gon7ttJSjAc9dkzayZzWwtsNKDf5mqz3gWYJs8VDDwHa', + token_id: null, + token_standard: 'NFT', + name: 'Rirewa Copper 33', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/GCJMQDpY6QE6ZkDGBiWQvojWdgHCG-vT-AzjBK06Qpg', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '33', + }, + { + key: 'Smile', + value: '50', + }, + { + key: 'Hug', + value: '29', + }, + { + key: 'Laugh', + value: '22', + }, + { + key: 'Heart', + value: '32', + }, + { + key: 'Warm', + value: '44', + }, + { + key: 'Good', + value: '42', + }, + { + key: 'Thanks', + value: '41', + }, + { + key: 'Share', + value: '32', + }, + { + key: 'Brave', + value: '36', + }, + { + key: 'Grow', + value: '2', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '77Q9CohBDAtcVViu8omnGPRuZ3NLEJmWTQSxS4NkNTb4', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '77Q9CohBDAtcVViu8omnGPRuZ3NLEJmWTQSxS4NkNTb4', + token_id: null, + token_standard: 'NFT', + name: 'Kukona Moss 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/CuBSyOFg1INUzBQYHXc2RJjeGnHq9-Z_y_aCRpPYBic', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Moss', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '41', + }, + { + key: 'Smile', + value: '43', + }, + { + key: 'Hug', + value: '25', + }, + { + key: 'Laugh', + value: '29', + }, + { + key: 'Heart', + value: '43', + }, + { + key: 'Warm', + value: '22', + }, + { + key: 'Good', + value: '59', + }, + { + key: 'Thanks', + value: '58', + }, + { + key: 'Share', + value: '52', + }, + { + key: 'Brave', + value: '4', + }, + { + key: 'Grow', + value: '75', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BEuGqAUFfxUokCpKWrt4vfWL9LVo2Eqrz5bHR2W7VVdY', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BEuGqAUFfxUokCpKWrt4vfWL9LVo2Eqrz5bHR2W7VVdY', + token_id: null, + token_standard: 'NFT', + name: 'Kukua Crimson 14', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/niam-C8liKefeRqrRvHLNT5ylrXGc6bMyvXndQ37LQM', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '14', + }, + { + key: 'Smile', + value: '17', + }, + { + key: 'Hug', + value: '21', + }, + { + key: 'Laugh', + value: '34', + }, + { + key: 'Heart', + value: '1', + }, + { + key: 'Warm', + value: '24', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '5', + }, + { + key: 'Share', + value: '12', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '24', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'UAdBHhEbn4e5EGu4JGi3jUocysjCTvfwafaLUPXjHEx', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'UAdBHhEbn4e5EGu4JGi3jUocysjCTvfwafaLUPXjHEx', + token_id: null, + token_standard: 'NFT', + name: 'Worika Atlantis 87', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Z-bNgDExYdMNG2RJhxe-eCEt1DCcZ3lV5Q723fiLfpE', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Violet Gold Blaze', + }, + { + key: 'Background', + value: 'Atlantis', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '87', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '87', + }, + { + key: 'Laugh', + value: '90', + }, + { + key: 'Heart', + value: '94', + }, + { + key: 'Warm', + value: '90', + }, + { + key: 'Good', + value: '77', + }, + { + key: 'Thanks', + value: '83', + }, + { + key: 'Share', + value: '95', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '65', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '97vAn6ZDWZyDzaa2ewvrNzeebr349DbufMHmboQiKpoQ', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '97vAn6ZDWZyDzaa2ewvrNzeebr349DbufMHmboQiKpoQ', + token_id: null, + token_standard: 'NFT', + name: 'Fumemi Umber 53', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/9PppdeVSQVwDnI35yOfSdFB0Np-d98HPGntDiigzG0g', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '53', + }, + { + key: 'Smile', + value: '53', + }, + { + key: 'Hug', + value: '38', + }, + { + key: 'Laugh', + value: '68', + }, + { + key: 'Heart', + value: '53', + }, + { + key: 'Warm', + value: '56', + }, + { + key: 'Good', + value: '63', + }, + { + key: 'Thanks', + value: '41', + }, + { + key: 'Share', + value: '68', + }, + { + key: 'Brave', + value: '81', + }, + { + key: 'Grow', + value: '9', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'AN9wcB4HifAz8Tze6ttKDroLtPEPfnQVhxebh5wcqV1X', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'AN9wcB4HifAz8Tze6ttKDroLtPEPfnQVhxebh5wcqV1X', + token_id: null, + token_standard: 'NFT', + name: 'Waware Copper 75', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreifrxhngg6slpjuvprxtsgtxfjsmeiimraplsihp47r5zaq4ucmpyu', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '75', + }, + { + key: 'Smile', + value: '82', + }, + { + key: 'Hug', + value: '74', + }, + { + key: 'Laugh', + value: '82', + }, + { + key: 'Heart', + value: '84', + }, + { + key: 'Warm', + value: '77', + }, + { + key: 'Good', + value: '80', + }, + { + key: 'Thanks', + value: '77', + }, + { + key: 'Share', + value: '73', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '21', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'EBjLNh3EUycToAex1da7FVEWyXbpHk9GdQPEJvZqoFhY', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'EBjLNh3EUycToAex1da7FVEWyXbpHk9GdQPEJvZqoFhY', + token_id: null, + token_standard: 'NFT', + name: 'Rikime Blush 50', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/HOhNC_W705buA9TDbiEcEhLTmihNRZALn-BBGcyBhbo', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '50', + }, + { + key: 'Smile', + value: '59', + }, + { + key: 'Hug', + value: '43', + }, + { + key: 'Laugh', + value: '54', + }, + { + key: 'Heart', + value: '39', + }, + { + key: 'Warm', + value: '54', + }, + { + key: 'Good', + value: '57', + }, + { + key: 'Thanks', + value: '47', + }, + { + key: 'Share', + value: '63', + }, + { + key: 'Brave', + value: '30', + }, + { + key: 'Grow', + value: '54', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'DGQwzn1DNzyTKYZW6c7PSshGfi8BeU5YhkYtoea7xYuz', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'DGQwzn1DNzyTKYZW6c7PSshGfi8BeU5YhkYtoea7xYuz', + token_id: null, + token_standard: 'NFT', + name: 'Kemata Azure 46', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/ZDe1CpPqRMw1lWCbabiMCicDDgCLlRh3sN3-Um4lX8U', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '46', + }, + { + key: 'Smile', + value: '51', + }, + { + key: 'Hug', + value: '26', + }, + { + key: 'Laugh', + value: '49', + }, + { + key: 'Heart', + value: '43', + }, + { + key: 'Warm', + value: '38', + }, + { + key: 'Good', + value: '46', + }, + { + key: 'Thanks', + value: '64', + }, + { + key: 'Share', + value: '34', + }, + { + key: 'Brave', + value: '41', + }, + { + key: 'Grow', + value: '68', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '9NP82fWe2gw7F1wNeFWp8Ytcz3x5H8FH5w2CJr9evk3T', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '9NP82fWe2gw7F1wNeFWp8Ytcz3x5H8FH5w2CJr9evk3T', + token_id: null, + token_standard: 'NFT', + name: 'Sakotsu Umber 32', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/s9SCmcGZl4sDejvngTKsXI3Bp5o-3_EfVXh5vQfy0kI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '32', + }, + { + key: 'Smile', + value: '15', + }, + { + key: 'Hug', + value: '32', + }, + { + key: 'Laugh', + value: '29', + }, + { + key: 'Heart', + value: '25', + }, + { + key: 'Warm', + value: '22', + }, + { + key: 'Good', + value: '25', + }, + { + key: 'Thanks', + value: '36', + }, + { + key: 'Share', + value: '31', + }, + { + key: 'Brave', + value: '7', + }, + { + key: 'Grow', + value: '98', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GjMQxQYGgB4dmPUyox6Y8gPscc4xcPR1nCfBRbXxKfv3', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GjMQxQYGgB4dmPUyox6Y8gPscc4xcPR1nCfBRbXxKfv3', + token_id: null, + token_standard: 'NFT', + name: 'Rikike Blush 33', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/jx68DdlTNH-oA9NJndW0uo7y3ntTKklR094oAGF_-x4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '33', + }, + { + key: 'Smile', + value: '26', + }, + { + key: 'Hug', + value: '39', + }, + { + key: 'Laugh', + value: '43', + }, + { + key: 'Heart', + value: '23', + }, + { + key: 'Warm', + value: '28', + }, + { + key: 'Good', + value: '45', + }, + { + key: 'Thanks', + value: '39', + }, + { + key: 'Share', + value: '26', + }, + { + key: 'Brave', + value: '37', + }, + { + key: 'Grow', + value: '24', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BMhpzoc1oZbqoxiifrr8pyCVDW1G1pFa4YrrqnZ1Q5By', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BMhpzoc1oZbqoxiifrr8pyCVDW1G1pFa4YrrqnZ1Q5By', + token_id: null, + token_standard: 'NFT', + name: 'Norae Slate 49', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/tjDj3ySxgnjLHUF6PkL_I2_x_D7-O0Rvq2jMoMf-XDY', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '49', + }, + { + key: 'Smile', + value: '43', + }, + { + key: 'Hug', + value: '60', + }, + { + key: 'Laugh', + value: '64', + }, + { + key: 'Heart', + value: '54', + }, + { + key: 'Warm', + value: '45', + }, + { + key: 'Good', + value: '38', + }, + { + key: 'Thanks', + value: '31', + }, + { + key: 'Share', + value: '38', + }, + { + key: 'Brave', + value: '51', + }, + { + key: 'Grow', + value: '66', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'DUar8NL7do1KHdu3a8S5gr9W3rcQ8uRJ7D8oxbMagGZH', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'DUar8NL7do1KHdu3a8S5gr9W3rcQ8uRJ7D8oxbMagGZH', + token_id: null, + token_standard: 'NFT', + name: 'Remima Steel 36', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/JjCXf-xvfAr7x69zh99VkIf97bCT6UCg7BV3zHX8Fdk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Steel', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '36', + }, + { + key: 'Smile', + value: '38', + }, + { + key: 'Hug', + value: '36', + }, + { + key: 'Laugh', + value: '17', + }, + { + key: 'Heart', + value: '34', + }, + { + key: 'Warm', + value: '47', + }, + { + key: 'Good', + value: '41', + }, + { + key: 'Thanks', + value: '54', + }, + { + key: 'Share', + value: '22', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '70', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'CS1EPRuna4uFjMyx8SsTQbSizK1bYNdafjS81sqt78Yb', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'CS1EPRuna4uFjMyx8SsTQbSizK1bYNdafjS81sqt78Yb', + token_id: null, + token_standard: 'NFT', + name: 'Nifue Blush 49', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/dCq8MRBzYrT6btmMuB8REAEofLbjsTZmAim2rwCVmVs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '49', + }, + { + key: 'Smile', + value: '41', + }, + { + key: 'Hug', + value: '39', + }, + { + key: 'Laugh', + value: '32', + }, + { + key: 'Heart', + value: '32', + }, + { + key: 'Warm', + value: '56', + }, + { + key: 'Good', + value: '55', + }, + { + key: 'Thanks', + value: '53', + }, + { + key: 'Share', + value: '50', + }, + { + key: 'Brave', + value: '42', + }, + { + key: 'Grow', + value: '90', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GBhfyvjTDLVxbUrgiFw9vmTx2YAomQKM7DchPAfXkykM', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GBhfyvjTDLVxbUrgiFw9vmTx2YAomQKM7DchPAfXkykM', + token_id: null, + token_standard: 'NFT', + name: 'Kohatsu Azure 9', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/G-FAkEF-lbMVz0hGH0Y-s-J_shnUwoP56qJ3qSCoz-Y', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Rose', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '9', + }, + { + key: 'Smile', + value: '7', + }, + { + key: 'Hug', + value: '1', + }, + { + key: 'Laugh', + value: '10', + }, + { + key: 'Heart', + value: '4', + }, + { + key: 'Warm', + value: '10', + }, + { + key: 'Good', + value: '7', + }, + { + key: 'Thanks', + value: '7', + }, + { + key: 'Share', + value: '15', + }, + { + key: 'Brave', + value: '7', + }, + { + key: 'Grow', + value: '22', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '51qZDbaqtLrMpWuKsEDr2LxmWmvoXqWQKasG2U5JEgJ', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '51qZDbaqtLrMpWuKsEDr2LxmWmvoXqWQKasG2U5JEgJ', + token_id: null, + token_standard: 'NFT', + name: 'Hohana Black 99', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/0R7VcDSywaw2E0EmRvnQUFMYX3jyVcRkR3bJudpFqVU', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Black', + }, + { + key: 'Rarity', + value: 'Legendary', + }, + { + key: 'Rating', + value: '99', + }, + { + key: 'Smile', + value: '100', + }, + { + key: 'Hug', + value: '95', + }, + { + key: 'Laugh', + value: '94', + }, + { + key: 'Heart', + value: '98', + }, + { + key: 'Warm', + value: '91', + }, + { + key: 'Good', + value: '95', + }, + { + key: 'Thanks', + value: '96', + }, + { + key: 'Share', + value: '91', + }, + { + key: 'Brave', + value: '94', + }, + { + key: 'Grow', + value: '91', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'H91LyFi3XKxMKbks6NgMAHv6kgFo2ZmJVM7QCqa5ibAz', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'H91LyFi3XKxMKbks6NgMAHv6kgFo2ZmJVM7QCqa5ibAz', + token_id: null, + token_standard: 'NFT', + name: 'Hokita Copper 8', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/nCiXVEca5CACx5AUbEgBXZuV0tmCDwMesXDHlMPNabs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '8', + }, + { + key: 'Smile', + value: '1', + }, + { + key: 'Hug', + value: '1', + }, + { + key: 'Laugh', + value: '14', + }, + { + key: 'Heart', + value: '8', + }, + { + key: 'Warm', + value: '1', + }, + { + key: 'Good', + value: '10', + }, + { + key: 'Thanks', + value: '1', + }, + { + key: 'Share', + value: '9', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '34', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2EnwiirRkhuiqru8s8si6gB2TPuc6BiUSQR7mAjFmGbi', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2EnwiirRkhuiqru8s8si6gB2TPuc6BiUSQR7mAjFmGbi', + token_id: null, + token_standard: 'NFT', + name: 'Nakiu Black 100', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/e0KFwCk42RjRVYDGHTzmER9DBp3k6tDJjxGvpDHf6Ys', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Black', + }, + { + key: 'Rarity', + value: 'Legendary', + }, + { + key: 'Rating', + value: '100', + }, + { + key: 'Smile', + value: '100', + }, + { + key: 'Hug', + value: '98', + }, + { + key: 'Laugh', + value: '95', + }, + { + key: 'Heart', + value: '92', + }, + { + key: 'Warm', + value: '91', + }, + { + key: 'Good', + value: '95', + }, + { + key: 'Thanks', + value: '99', + }, + { + key: 'Share', + value: '90', + }, + { + key: 'Brave', + value: '99', + }, + { + key: 'Grow', + value: '94', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '3trwQkC9AnrsRwMJTXCj9TaaCYnULzbwjdnVu1M3Tg8n', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '3trwQkC9AnrsRwMJTXCj9TaaCYnULzbwjdnVu1M3Tg8n', + token_id: null, + token_standard: 'NFT', + name: 'Morate Slate 23', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/FzUJDcW2a37XolloFxvLJE-e4S60Mg_X-cT3-UPHPWk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '23', + }, + { + key: 'Smile', + value: '38', + }, + { + key: 'Hug', + value: '22', + }, + { + key: 'Laugh', + value: '4', + }, + { + key: 'Heart', + value: '16', + }, + { + key: 'Warm', + value: '15', + }, + { + key: 'Good', + value: '16', + }, + { + key: 'Thanks', + value: '19', + }, + { + key: 'Share', + value: '33', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '66', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BgXSiLx46j66K3dPKrkEzqpNah6yPLVCdMaUNexJkBuR', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BgXSiLx46j66K3dPKrkEzqpNah6yPLVCdMaUNexJkBuR', + token_id: null, + token_standard: 'NFT', + name: 'Kanari Umber 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/us3c52RJ0zE1APAZ-Ru7ZwC22xWIQblnkecg1JAhUsk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '30', + }, + { + key: 'Smile', + value: '45', + }, + { + key: 'Hug', + value: '39', + }, + { + key: 'Laugh', + value: '16', + }, + { + key: 'Heart', + value: '35', + }, + { + key: 'Warm', + value: '18', + }, + { + key: 'Good', + value: '22', + }, + { + key: 'Thanks', + value: '28', + }, + { + key: 'Share', + value: '11', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '85', + }, + ], + token_account_address: null, + creators: [ + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'DLAadn6cwU3zEWfdT8ysG9aYZX3dREcD4Zq7tu7oaWaY', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'DLAadn6cwU3zEWfdT8ysG9aYZX3dREcD4Zq7tu7oaWaY', + token_id: null, + token_standard: 'NFT', + name: 'Rusese Azure 78', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/cA09R_D2QlZqwM47bb7hj0JqO7RJ3vfY2pfEbOFn0nk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '78', + }, + { + key: 'Smile', + value: '84', + }, + { + key: 'Hug', + value: '81', + }, + { + key: 'Laugh', + value: '73', + }, + { + key: 'Heart', + value: '79', + }, + { + key: 'Warm', + value: '88', + }, + { + key: 'Good', + value: '72', + }, + { + key: 'Thanks', + value: '88', + }, + { + key: 'Share', + value: '68', + }, + { + key: 'Brave', + value: '94', + }, + { + key: 'Grow', + value: '53', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'rfsqYespebrV4LHpc9bBrjVizZdtsFLMn3u8BrH8WA2', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'rfsqYespebrV4LHpc9bBrjVizZdtsFLMn3u8BrH8WA2', + token_id: null, + token_standard: 'NFT', + name: 'Suresa Umber 24', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreigohb7vkelgqdvuozf77tsbfzgyoreb7kfdgzwpxspp5npqbgwlwq', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Coral', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '24', + }, + { + key: 'Smile', + value: '32', + }, + { + key: 'Hug', + value: '21', + }, + { + key: 'Laugh', + value: '13', + }, + { + key: 'Heart', + value: '20', + }, + { + key: 'Warm', + value: '40', + }, + { + key: 'Good', + value: '23', + }, + { + key: 'Thanks', + value: '7', + }, + { + key: 'Share', + value: '18', + }, + { + key: 'Brave', + value: '38', + }, + { + key: 'Grow', + value: '28', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4c8w7UELpYiLSebt9LBDHwqo1sSms6AHQPhLaGchDmXK', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4c8w7UELpYiLSebt9LBDHwqo1sSms6AHQPhLaGchDmXK', + token_id: null, + token_standard: 'NFT', + name: 'Korihi Umber 77', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/pamBCJnH9I7UEiCZLTE5991Y3OGlDWi9hs-8LcdY9Ew', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '77', + }, + { + key: 'Smile', + value: '85', + }, + { + key: 'Hug', + value: '85', + }, + { + key: 'Laugh', + value: '74', + }, + { + key: 'Heart', + value: '75', + }, + { + key: 'Warm', + value: '77', + }, + { + key: 'Good', + value: '71', + }, + { + key: 'Thanks', + value: '76', + }, + { + key: 'Share', + value: '68', + }, + { + key: 'Brave', + value: '69', + }, + { + key: 'Grow', + value: '90', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'ATD2ZB348yVE3TmLdpTCG5RCNWVDv6fvVxbbUWZ4bMuD', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'ATD2ZB348yVE3TmLdpTCG5RCNWVDv6fvVxbbUWZ4bMuD', + token_id: null, + token_standard: 'NFT', + name: 'Niroro Blush 78', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/mF-GKGokpgVTeRbx4l-GrPnZNynOaNK5D7euuclgiTI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Violet', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '78', + }, + { + key: 'Smile', + value: '88', + }, + { + key: 'Hug', + value: '68', + }, + { + key: 'Laugh', + value: '68', + }, + { + key: 'Heart', + value: '87', + }, + { + key: 'Warm', + value: '88', + }, + { + key: 'Good', + value: '68', + }, + { + key: 'Thanks', + value: '81', + }, + { + key: 'Share', + value: '79', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '53', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5NER4zk67boQSyBRpXkctXx9spWdCWghjvLKAJRUqMqh', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5NER4zk67boQSyBRpXkctXx9spWdCWghjvLKAJRUqMqh', + token_id: null, + token_standard: 'NFT', + name: 'Yoohe Steel 23', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/5FywtmfMyZpuEkiOuMznAkbonLXVEp7CVd07or738XI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Steel', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '23', + }, + { + key: 'Smile', + value: '30', + }, + { + key: 'Hug', + value: '41', + }, + { + key: 'Laugh', + value: '8', + }, + { + key: 'Heart', + value: '4', + }, + { + key: 'Warm', + value: '30', + }, + { + key: 'Good', + value: '5', + }, + { + key: 'Thanks', + value: '7', + }, + { + key: 'Share', + value: '27', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '77', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '77NqHK3N4iMb5h3WdJ97m9ZwvTo2E5kGii3vJ4CgMcjV', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '77NqHK3N4iMb5h3WdJ97m9ZwvTo2E5kGii3vJ4CgMcjV', + token_id: null, + token_standard: 'NFT', + name: 'Yusuno Slate 27', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/yLEJcqvDJY-qpltJo1fMlxMJ87S3qMLtFN1pFvgXQSQ', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '27', + }, + { + key: 'Smile', + value: '8', + }, + { + key: 'Hug', + value: '21', + }, + { + key: 'Laugh', + value: '20', + }, + { + key: 'Heart', + value: '37', + }, + { + key: 'Warm', + value: '29', + }, + { + key: 'Good', + value: '10', + }, + { + key: 'Thanks', + value: '39', + }, + { + key: 'Share', + value: '38', + }, + { + key: 'Brave', + value: '7', + }, + { + key: 'Grow', + value: '61', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '47v8QuQVbi1vMpQPD1omtD576azLVLnZRDKnbMRE4UVc', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '47v8QuQVbi1vMpQPD1omtD576azLVLnZRDKnbMRE4UVc', + token_id: null, + token_standard: 'NFT', + name: 'Nuwase Lilac 75', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreieibgslgahlx7msh7nygks3b5ootzbaz7tgeduss5nogylcgxhufu', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Lilac', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '75', + }, + { + key: 'Smile', + value: '71', + }, + { + key: 'Hug', + value: '83', + }, + { + key: 'Laugh', + value: '66', + }, + { + key: 'Heart', + value: '80', + }, + { + key: 'Warm', + value: '72', + }, + { + key: 'Good', + value: '77', + }, + { + key: 'Thanks', + value: '69', + }, + { + key: 'Share', + value: '72', + }, + { + key: 'Brave', + value: '62', + }, + { + key: 'Grow', + value: '98', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4hnxWEfvWRPDECXivcd4KQ8b72f7EpTLW3ahLfTsE1SK', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4hnxWEfvWRPDECXivcd4KQ8b72f7EpTLW3ahLfTsE1SK', + token_id: null, + token_standard: 'NFT', + name: 'Suniku Volcano 91', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/68sOcz3f7BeI0TwlYi3akg2lvOJKmICZeviz9Mg0IOA', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold Blaze Rose', + }, + { + key: 'Background', + value: 'Volcano', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '91', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '88', + }, + { + key: 'Laugh', + value: '100', + }, + { + key: 'Heart', + value: '91', + }, + { + key: 'Warm', + value: '98', + }, + { + key: 'Good', + value: '82', + }, + { + key: 'Thanks', + value: '94', + }, + { + key: 'Share', + value: '85', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '83', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '6xc3gCdFiXjaThH9VPeLE4THp7spDvrbuQJSjdNAq8ZC', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '6xc3gCdFiXjaThH9VPeLE4THp7spDvrbuQJSjdNAq8ZC', + token_id: null, + token_standard: 'NFT', + name: 'Nimira Azure 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/aHXL3LNLt5yVs4YlNQf85g7Ze4pSRusQ2tvjV29bWC0', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '41', + }, + { + key: 'Smile', + value: '51', + }, + { + key: 'Hug', + value: '56', + }, + { + key: 'Laugh', + value: '46', + }, + { + key: 'Heart', + value: '38', + }, + { + key: 'Warm', + value: '34', + }, + { + key: 'Good', + value: '54', + }, + { + key: 'Thanks', + value: '42', + }, + { + key: 'Share', + value: '55', + }, + { + key: 'Brave', + value: '29', + }, + { + key: 'Grow', + value: '5', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'AoWSoRA7fKd1siZzzJyDwP1wmGrgTy4XaxPStPzRSCqC', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'AoWSoRA7fKd1siZzzJyDwP1wmGrgTy4XaxPStPzRSCqC', + token_id: null, + token_standard: 'NFT', + name: 'Nofun Slate 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/P6RJ6EoXbebO-gWjTo-P1cZr85Qh1UyWSuDN2F7-BXs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Rose', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '30', + }, + { + key: 'Smile', + value: '19', + }, + { + key: 'Hug', + value: '32', + }, + { + key: 'Laugh', + value: '31', + }, + { + key: 'Heart', + value: '38', + }, + { + key: 'Warm', + value: '48', + }, + { + key: 'Good', + value: '14', + }, + { + key: 'Thanks', + value: '16', + }, + { + key: 'Share', + value: '35', + }, + { + key: 'Brave', + value: '24', + }, + { + key: 'Grow', + value: '43', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '8D2Yut6B6vNvGa3JmwTf8mBPjUbaP2VDRtBdjFoyTiMx', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '8D2Yut6B6vNvGa3JmwTf8mBPjUbaP2VDRtBdjFoyTiMx', + token_id: null, + token_standard: 'NFT', + name: 'Tokau Nostalgia 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/VsUufMJMwyTjaoaExI87RlgNWRmYrVKTmsQVI88__qQ', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe Mint Gold', + }, + { + key: 'Background', + value: 'Nostalgia', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '86', + }, + { + key: 'Smile', + value: '86', + }, + { + key: 'Hug', + value: '88', + }, + { + key: 'Laugh', + value: '93', + }, + { + key: 'Heart', + value: '93', + }, + { + key: 'Warm', + value: '79', + }, + { + key: 'Good', + value: '80', + }, + { + key: 'Thanks', + value: '92', + }, + { + key: 'Share', + value: '96', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '53', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '91xa3Mo4mkMV7ER6eWX7h4GH9xbBpTgVgyt36Ncqfq18', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '91xa3Mo4mkMV7ER6eWX7h4GH9xbBpTgVgyt36Ncqfq18', + token_id: null, + token_standard: 'NFT', + name: 'Mesoru Lilac 81', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/t-vYtwb7BFjG0w3AvpQvwunUa4mq8PHH60c0yhiKDFU', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Lilac', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '81', + }, + { + key: 'Smile', + value: '72', + }, + { + key: 'Hug', + value: '81', + }, + { + key: 'Laugh', + value: '90', + }, + { + key: 'Heart', + value: '74', + }, + { + key: 'Warm', + value: '85', + }, + { + key: 'Good', + value: '83', + }, + { + key: 'Thanks', + value: '90', + }, + { + key: 'Share', + value: '76', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '59', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '3Z8mMqu7zGpjuU6FbA5CfitNCXr7eSc3RfTswh1AcC6t', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '3Z8mMqu7zGpjuU6FbA5CfitNCXr7eSc3RfTswh1AcC6t', + token_id: null, + token_standard: 'NFT', + name: 'Nihaho Red 98', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/EjQtl3DxC4wSRkhPpeoq2TkPGnNfUI9CVwuaWtigP-8', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Red', + }, + { + key: 'Rarity', + value: 'Legendary', + }, + { + key: 'Rating', + value: '98', + }, + { + key: 'Smile', + value: '93', + }, + { + key: 'Hug', + value: '95', + }, + { + key: 'Laugh', + value: '94', + }, + { + key: 'Heart', + value: '94', + }, + { + key: 'Warm', + value: '98', + }, + { + key: 'Good', + value: '91', + }, + { + key: 'Thanks', + value: '95', + }, + { + key: 'Share', + value: '91', + }, + { + key: 'Brave', + value: '96', + }, + { + key: 'Grow', + value: '97', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4KBDR1fKyzDAjWxdN9RtGqDrFA3w7jDHXtQ3midGXDiK', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4KBDR1fKyzDAjWxdN9RtGqDrFA3w7jDHXtQ3midGXDiK', + token_id: null, + token_standard: 'NFT', + name: 'Kusete Nostalgia 88', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/r70dK6iosoPJ-e80ao_PzTC9hamuqjZtVTifyax5H5A', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime Rose Mint', + }, + { + key: 'Background', + value: 'Nostalgia', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '88', + }, + { + key: 'Smile', + value: '92', + }, + { + key: 'Hug', + value: '93', + }, + { + key: 'Laugh', + value: '94', + }, + { + key: 'Heart', + value: '81', + }, + { + key: 'Warm', + value: '84', + }, + { + key: 'Good', + value: '81', + }, + { + key: 'Thanks', + value: '93', + }, + { + key: 'Share', + value: '87', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '75', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5yWqBNq7xBYdU3xvWuBgv5zznyY1KQuybo1X6Rn3Y5ti', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5yWqBNq7xBYdU3xvWuBgv5zznyY1KQuybo1X6Rn3Y5ti', + token_id: null, + token_standard: 'NFT', + name: 'Maumi Copper 50', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Ac24TFEtdJlP8rLmwT89IP8xxFpDg_h44WtyxEla7ZQ', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '50', + }, + { + key: 'Smile', + value: '38', + }, + { + key: 'Hug', + value: '52', + }, + { + key: 'Laugh', + value: '48', + }, + { + key: 'Heart', + value: '30', + }, + { + key: 'Warm', + value: '58', + }, + { + key: 'Good', + value: '34', + }, + { + key: 'Thanks', + value: '67', + }, + { + key: 'Share', + value: '33', + }, + { + key: 'Brave', + value: '47', + }, + { + key: 'Grow', + value: '93', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '8Ruk1TR2pm8e6T2bMZjBqL5aFwq9BPwe9J65rzHMAcek', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '8Ruk1TR2pm8e6T2bMZjBqL5aFwq9BPwe9J65rzHMAcek', + token_id: null, + token_standard: 'NFT', + name: 'Uahi Steel 35', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreiags6ha7fxshutnsfmarer6xknuri2hio7ne7lz4c5eubtkpco6mm', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Rose', + }, + { + key: 'Background', + value: 'Steel', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '35', + }, + { + key: 'Smile', + value: '31', + }, + { + key: 'Hug', + value: '45', + }, + { + key: 'Laugh', + value: '16', + }, + { + key: 'Heart', + value: '29', + }, + { + key: 'Warm', + value: '53', + }, + { + key: 'Good', + value: '34', + }, + { + key: 'Thanks', + value: '48', + }, + { + key: 'Share', + value: '48', + }, + { + key: 'Brave', + value: '38', + }, + { + key: 'Grow', + value: '8', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'E1cBh6oyu8aMcsFGHqkXUYxamhCV1LTnRGCEUXcmpwf9', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'E1cBh6oyu8aMcsFGHqkXUYxamhCV1LTnRGCEUXcmpwf9', + token_id: null, + token_standard: 'NFT', + name: 'Himefu Moss 63', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/3eoPW3uLLh7n0zMPSAq_w1jV8qPaNnTU965QxkeCgI4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Moss', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '63', + }, + { + key: 'Smile', + value: '50', + }, + { + key: 'Hug', + value: '73', + }, + { + key: 'Laugh', + value: '70', + }, + { + key: 'Heart', + value: '76', + }, + { + key: 'Warm', + value: '72', + }, + { + key: 'Good', + value: '55', + }, + { + key: 'Thanks', + value: '73', + }, + { + key: 'Share', + value: '63', + }, + { + key: 'Brave', + value: '60', + }, + { + key: 'Grow', + value: '38', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '76unMz9mTcRmwxBdu5heBwgbwzSPRYZK9yYiTW4KEMi2', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '76unMz9mTcRmwxBdu5heBwgbwzSPRYZK9yYiTW4KEMi2', + token_id: null, + token_standard: 'NFT', + name: 'Hetoo Copper 43', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/HX2sDwqB7oXie9Rw2WEPjJmVV1Opi9cG_BwEHopeJRw', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '43', + }, + { + key: 'Smile', + value: '63', + }, + { + key: 'Hug', + value: '53', + }, + { + key: 'Laugh', + value: '44', + }, + { + key: 'Heart', + value: '25', + }, + { + key: 'Warm', + value: '55', + }, + { + key: 'Good', + value: '29', + }, + { + key: 'Thanks', + value: '61', + }, + { + key: 'Share', + value: '35', + }, + { + key: 'Brave', + value: '44', + }, + { + key: 'Grow', + value: '21', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'FDjX1GKUaBDkETH34Be3qZ5PxUMNDR3hoSjaaYAmrBwG', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'FDjX1GKUaBDkETH34Be3qZ5PxUMNDR3hoSjaaYAmrBwG', + token_id: null, + token_standard: 'NFT', + name: 'Nekuse Volcano 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/jL22y8u0_f3KLmhYB_JJ9QXGBXuLAoTPe6UtwkwJM-4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime Rose Gold', + }, + { + key: 'Background', + value: 'Volcano', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '89', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '85', + }, + { + key: 'Laugh', + value: '88', + }, + { + key: 'Heart', + value: '88', + }, + { + key: 'Warm', + value: '84', + }, + { + key: 'Good', + value: '86', + }, + { + key: 'Thanks', + value: '92', + }, + { + key: 'Share', + value: '97', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '81', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'B9FQr2NUb9Z9eTRzH8DYBhd5z9pRwfYVEwazzVQn8gF4', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'B9FQr2NUb9Z9eTRzH8DYBhd5z9pRwfYVEwazzVQn8gF4', + token_id: null, + token_standard: 'NFT', + name: 'Seashi Blush 62', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/vkW26LNNfKknVM7uoDY8EssRpJ-xAyeR7DIe2accOwI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '62', + }, + { + key: 'Smile', + value: '54', + }, + { + key: 'Hug', + value: '46', + }, + { + key: 'Laugh', + value: '81', + }, + { + key: 'Heart', + value: '57', + }, + { + key: 'Warm', + value: '44', + }, + { + key: 'Good', + value: '80', + }, + { + key: 'Thanks', + value: '71', + }, + { + key: 'Share', + value: '70', + }, + { + key: 'Brave', + value: '21', + }, + { + key: 'Grow', + value: '96', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'Goia9fJhcQwjixixzDA97TJ5QkiCCarE7HgRaQyzEFFN', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'Goia9fJhcQwjixixzDA97TJ5QkiCCarE7HgRaQyzEFFN', + token_id: null, + token_standard: 'NFT', + name: 'Hewasu Copper 12', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/SIuKlVtnFNrbLynJefY4COWrKIk6YXF1omAdRRiDXh8', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '12', + }, + { + key: 'Smile', + value: '24', + }, + { + key: 'Hug', + value: '1', + }, + { + key: 'Laugh', + value: '2', + }, + { + key: 'Heart', + value: '24', + }, + { + key: 'Warm', + value: '15', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '30', + }, + { + key: 'Share', + value: '16', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '6', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'HGaT3H4fGaDrj9YBzSALqqBDPTGuRmfsXT6RXSLTnMNX', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'HGaT3H4fGaDrj9YBzSALqqBDPTGuRmfsXT6RXSLTnMNX', + token_id: null, + token_standard: 'NFT', + name: 'Ioyu Steel 64', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/k1AHX_ElRu4RkN_V_-eU_y8molElD1sqxB8mzeeI45E', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Steel', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '64', + }, + { + key: 'Smile', + value: '61', + }, + { + key: 'Hug', + value: '77', + }, + { + key: 'Laugh', + value: '63', + }, + { + key: 'Heart', + value: '61', + }, + { + key: 'Warm', + value: '52', + }, + { + key: 'Good', + value: '52', + }, + { + key: 'Thanks', + value: '52', + }, + { + key: 'Share', + value: '66', + }, + { + key: 'Brave', + value: '86', + }, + { + key: 'Grow', + value: '70', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'F2UFnyDs6ywQBtQfijF98quBzFzkjkzNr9z7eMpFnKQn', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'F2UFnyDs6ywQBtQfijF98quBzFzkjkzNr9z7eMpFnKQn', + token_id: null, + token_standard: 'NFT', + name: 'Sunoso Crimson 11', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreifr4pwqjuvx2mpj6rhvtdiglclm25gznjf7lpnz3ruyx77zycg2fa', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '11', + }, + { + key: 'Smile', + value: '1', + }, + { + key: 'Hug', + value: '11', + }, + { + key: 'Laugh', + value: '31', + }, + { + key: 'Heart', + value: '1', + }, + { + key: 'Warm', + value: '15', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '3', + }, + { + key: 'Share', + value: '8', + }, + { + key: 'Brave', + value: '23', + }, + { + key: 'Grow', + value: '16', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '6TXuzQWRK6nJkh93gpfk5dP57sKTd8rNZAMpP4ng3rHj', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '6TXuzQWRK6nJkh93gpfk5dP57sKTd8rNZAMpP4ng3rHj', + token_id: null, + token_standard: 'NFT', + name: 'Amuha Crimson 41', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/GLeaJtBoChF7YDTSTpHhBzM8TJDlDUop9teSfDMbd4k', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '41', + }, + { + key: 'Smile', + value: '48', + }, + { + key: 'Hug', + value: '43', + }, + { + key: 'Laugh', + value: '58', + }, + { + key: 'Heart', + value: '58', + }, + { + key: 'Warm', + value: '31', + }, + { + key: 'Good', + value: '48', + }, + { + key: 'Thanks', + value: '60', + }, + { + key: 'Share', + value: '45', + }, + { + key: 'Brave', + value: '12', + }, + { + key: 'Grow', + value: '7', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '6wbXdTidWfxjsDTrdXo9LyhWBLk4zPhi2pSsu93gj48U', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '6wbXdTidWfxjsDTrdXo9LyhWBLk4zPhi2pSsu93gj48U', + token_id: null, + token_standard: 'NFT', + name: 'Hakaya Stage 95', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Q-xsuhCPw5Js8UTDCYr7qu5jtSAL-Nwrf99N11hhPi4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Butter Taupe Rose', + }, + { + key: 'Background', + value: 'Stage', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '95', + }, + { + key: 'Smile', + value: '91', + }, + { + key: 'Hug', + value: '96', + }, + { + key: 'Laugh', + value: '93', + }, + { + key: 'Heart', + value: '93', + }, + { + key: 'Warm', + value: '95', + }, + { + key: 'Good', + value: '100', + }, + { + key: 'Thanks', + value: '94', + }, + { + key: 'Share', + value: '91', + }, + { + key: 'Brave', + value: '97', + }, + { + key: 'Grow', + value: '100', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'AnHfaZXNv1zztXkAsKqDyQew77TBaq8EkRcNPS8TjqYK', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'AnHfaZXNv1zztXkAsKqDyQew77TBaq8EkRcNPS8TjqYK', + token_id: null, + token_standard: 'NFT', + name: 'Shihitsu Azure 84', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/EJUSKNEz23WE_yqaq4NQ-5GQTjmShTic0DgeJ1mP8Tg', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '84', + }, + { + key: 'Smile', + value: '82', + }, + { + key: 'Hug', + value: '84', + }, + { + key: 'Laugh', + value: '79', + }, + { + key: 'Heart', + value: '74', + }, + { + key: 'Warm', + value: '77', + }, + { + key: 'Good', + value: '94', + }, + { + key: 'Thanks', + value: '78', + }, + { + key: 'Share', + value: '79', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '93', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GoLgRrRMrBwNSQuX9GMnw9LNZrnLc457TaE3kcvhSGdC', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GoLgRrRMrBwNSQuX9GMnw9LNZrnLc457TaE3kcvhSGdC', + token_id: null, + token_standard: 'NFT', + name: 'Otowa Moss 68', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/VwMEUAk6U4EaHEWZW22t6BtGrA-fChg90jljLgtcSvs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Moss', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '68', + }, + { + key: 'Smile', + value: '54', + }, + { + key: 'Hug', + value: '75', + }, + { + key: 'Laugh', + value: '63', + }, + { + key: 'Heart', + value: '88', + }, + { + key: 'Warm', + value: '87', + }, + { + key: 'Good', + value: '76', + }, + { + key: 'Thanks', + value: '49', + }, + { + key: 'Share', + value: '84', + }, + { + key: 'Brave', + value: '88', + }, + { + key: 'Grow', + value: '16', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '8xrLyqfaVsbCe3xSvrqJrLhAbLnGdtMAbVs2FP79ZtzU', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '8xrLyqfaVsbCe3xSvrqJrLhAbLnGdtMAbVs2FP79ZtzU', + token_id: null, + token_standard: 'NFT', + name: 'Keyaho Slate 27', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/gNH7x8H7pKpBemac6V_KXahmhwxGm5QBsTfjzWt4k9k', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '27', + }, + { + key: 'Smile', + value: '39', + }, + { + key: 'Hug', + value: '46', + }, + { + key: 'Laugh', + value: '14', + }, + { + key: 'Heart', + value: '15', + }, + { + key: 'Warm', + value: '19', + }, + { + key: 'Good', + value: '19', + }, + { + key: 'Thanks', + value: '47', + }, + { + key: 'Share', + value: '34', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '36', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'JAFFsHva4Uo7GCCKbWeLFZs2uJgvDtPzqvJ2jRktDzx6', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'JAFFsHva4Uo7GCCKbWeLFZs2uJgvDtPzqvJ2jRktDzx6', + token_id: null, + token_standard: 'NFT', + name: 'Watohi Atlantis 92', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/BZHCO7179d1OXzJm0d7JRYAjiHQoGVA1nOYFD0huRCg', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky Blaze Mint', + }, + { + key: 'Background', + value: 'Atlantis', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '92', + }, + { + key: 'Smile', + value: '100', + }, + { + key: 'Hug', + value: '86', + }, + { + key: 'Laugh', + value: '82', + }, + { + key: 'Heart', + value: '98', + }, + { + key: 'Warm', + value: '96', + }, + { + key: 'Good', + value: '89', + }, + { + key: 'Thanks', + value: '90', + }, + { + key: 'Share', + value: '99', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '80', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '9CX1KT8wi4rvPFCU8WL8X2U8P6sxGavyAh9wpBrpSXvn', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '9CX1KT8wi4rvPFCU8WL8X2U8P6sxGavyAh9wpBrpSXvn', + token_id: null, + token_standard: 'NFT', + name: 'Inui Black 99', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Q7RfboNqOGM44NwzrSgiPhbvCccHCLgttfmT5sk9PwA', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Black', + }, + { + key: 'Rarity', + value: 'Legendary', + }, + { + key: 'Rating', + value: '99', + }, + { + key: 'Smile', + value: '93', + }, + { + key: 'Hug', + value: '99', + }, + { + key: 'Laugh', + value: '91', + }, + { + key: 'Heart', + value: '97', + }, + { + key: 'Warm', + value: '95', + }, + { + key: 'Good', + value: '96', + }, + { + key: 'Thanks', + value: '97', + }, + { + key: 'Share', + value: '97', + }, + { + key: 'Brave', + value: '94', + }, + { + key: 'Grow', + value: '94', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'FzBbVaUcUd5tkJTBU1WDMZRafQcZA7ygFpSVoJuc4PLj', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'FzBbVaUcUd5tkJTBU1WDMZRafQcZA7ygFpSVoJuc4PLj', + token_id: null, + token_standard: 'NFT', + name: 'Renhi Copper 72', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/E5zaT2ZoI34q8VAzi5znRVWVFB7UZ_ShisuolmfAoaE', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '72', + }, + { + key: 'Smile', + value: '76', + }, + { + key: 'Hug', + value: '53', + }, + { + key: 'Laugh', + value: '57', + }, + { + key: 'Heart', + value: '85', + }, + { + key: 'Warm', + value: '87', + }, + { + key: 'Good', + value: '80', + }, + { + key: 'Thanks', + value: '58', + }, + { + key: 'Share', + value: '87', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '37', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '23vXxoc1EDL46NtBfdpZ7vPwTWN6eYTXgFAG9JN2jo1U', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '23vXxoc1EDL46NtBfdpZ7vPwTWN6eYTXgFAG9JN2jo1U', + token_id: null, + token_standard: 'NFT', + name: 'Muwani Maize 34', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreifmsz5mfrepbl37autwckjwzvehxdmnunrifm7l2xhaippfght2hy', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Rose', + }, + { + key: 'Background', + value: 'Maize', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '34', + }, + { + key: 'Smile', + value: '43', + }, + { + key: 'Hug', + value: '22', + }, + { + key: 'Laugh', + value: '24', + }, + { + key: 'Heart', + value: '35', + }, + { + key: 'Warm', + value: '26', + }, + { + key: 'Good', + value: '30', + }, + { + key: 'Thanks', + value: '46', + }, + { + key: 'Share', + value: '14', + }, + { + key: 'Brave', + value: '33', + }, + { + key: 'Grow', + value: '67', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '3LQXhQ7hCjyGt5BP2ugJLeJwc4wSY7BhL8kN43DenjBm', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '3LQXhQ7hCjyGt5BP2ugJLeJwc4wSY7BhL8kN43DenjBm', + token_id: null, + token_standard: 'NFT', + name: 'Moriro Maize 39', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreig2xneuwtbhrncsv7f5isltw2rzfdbny2wrv3zpq2ayur3ckhjwyi', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Violet', + }, + { + key: 'Background', + value: 'Maize', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '39', + }, + { + key: 'Smile', + value: '20', + }, + { + key: 'Hug', + value: '27', + }, + { + key: 'Laugh', + value: '59', + }, + { + key: 'Heart', + value: '59', + }, + { + key: 'Warm', + value: '25', + }, + { + key: 'Good', + value: '28', + }, + { + key: 'Thanks', + value: '34', + }, + { + key: 'Share', + value: '44', + }, + { + key: 'Brave', + value: '75', + }, + { + key: 'Grow', + value: '19', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'DvsAFqxLgQPv5U4A7WezLcAtPQG4dMQPw7N3fEkdzuc3', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'DvsAFqxLgQPv5U4A7WezLcAtPQG4dMQPw7N3fEkdzuc3', + token_id: null, + token_standard: 'NFT', + name: 'Kifuwa Copper 81', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/JfWa1IF1jcMwB6dSRcJOkQXyBGCGQr2uERMqt0c0SGo', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '81', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '83', + }, + { + key: 'Laugh', + value: '77', + }, + { + key: 'Heart', + value: '74', + }, + { + key: 'Warm', + value: '91', + }, + { + key: 'Good', + value: '79', + }, + { + key: 'Thanks', + value: '84', + }, + { + key: 'Share', + value: '79', + }, + { + key: 'Brave', + value: '98', + }, + { + key: 'Grow', + value: '56', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2V6tUoD1vJZXpNX6YC1j3gGRuUFANJDuaJAV3M7M7Qtm', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2V6tUoD1vJZXpNX6YC1j3gGRuUFANJDuaJAV3M7M7Qtm', + token_id: null, + token_standard: 'NFT', + name: 'Namiku Crimson 79', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/ybG1mkP9DIFRLEAaiu8RU6zYZa_0ADNocZAgDXwVTOo', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '79', + }, + { + key: 'Smile', + value: '86', + }, + { + key: 'Hug', + value: '77', + }, + { + key: 'Laugh', + value: '82', + }, + { + key: 'Heart', + value: '82', + }, + { + key: 'Warm', + value: '72', + }, + { + key: 'Good', + value: '82', + }, + { + key: 'Thanks', + value: '81', + }, + { + key: 'Share', + value: '88', + }, + { + key: 'Brave', + value: '80', + }, + { + key: 'Grow', + value: '60', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '66ePeHXTVWjdpHbf9RirdmpgUJHJ9HumiZeH6vTstpxj', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '66ePeHXTVWjdpHbf9RirdmpgUJHJ9HumiZeH6vTstpxj', + token_id: null, + token_standard: 'NFT', + name: 'Heriho Blush 59', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/7Lkid1s0zRIOk_qugby0w0-s5McUZktp6p0R678_qD4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '59', + }, + { + key: 'Smile', + value: '64', + }, + { + key: 'Hug', + value: '44', + }, + { + key: 'Laugh', + value: '73', + }, + { + key: 'Heart', + value: '47', + }, + { + key: 'Warm', + value: '46', + }, + { + key: 'Good', + value: '57', + }, + { + key: 'Thanks', + value: '73', + }, + { + key: 'Share', + value: '69', + }, + { + key: 'Brave', + value: '54', + }, + { + key: 'Grow', + value: '63', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '8XgHPUSoBsh2MMjthCko8Dp11mji7oGhZ7FbfZre7K2Y', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '8XgHPUSoBsh2MMjthCko8Dp11mji7oGhZ7FbfZre7K2Y', + token_id: null, + token_standard: 'NFT', + name: 'Emishi Cavern 93', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreif6p2wmov37vfmuynq6m2nibf36tf5c3wrg7o4jr7zmoq77oaue7a', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Butter Blaze Sky', + }, + { + key: 'Background', + value: 'Cavern', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '93', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '92', + }, + { + key: 'Laugh', + value: '100', + }, + { + key: 'Heart', + value: '83', + }, + { + key: 'Warm', + value: '100', + }, + { + key: 'Good', + value: '100', + }, + { + key: 'Thanks', + value: '95', + }, + { + key: 'Share', + value: '100', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '71', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'A5PYKSjz55k7C5pia6vpSFjZbD4Ea8ZV2KYCY6PMfpKE', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'A5PYKSjz55k7C5pia6vpSFjZbD4Ea8ZV2KYCY6PMfpKE', + token_id: null, + token_standard: 'NFT', + name: 'Tehise Umber 69', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/FaOcKsrOodVH8r2ys1GEvdlGKTAek2N13-Wi5MdrV28', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '69', + }, + { + key: 'Smile', + value: '80', + }, + { + key: 'Hug', + value: '82', + }, + { + key: 'Laugh', + value: '71', + }, + { + key: 'Heart', + value: '71', + }, + { + key: 'Warm', + value: '83', + }, + { + key: 'Good', + value: '68', + }, + { + key: 'Thanks', + value: '53', + }, + { + key: 'Share', + value: '80', + }, + { + key: 'Brave', + value: '63', + }, + { + key: 'Grow', + value: '39', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '7vYE2auoFeNZ4i3jZ1dJ17N3iJUd6tqSc8NT3mx2Jvk2', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '7vYE2auoFeNZ4i3jZ1dJ17N3iJUd6tqSc8NT3mx2Jvk2', + token_id: null, + token_standard: 'NFT', + name: 'Mokise Azure 72', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/k8TkNfVdxmTH1Gc3yYnRL8cLL3t1jUAU2Zp42UTuH7k', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Coral', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '72', + }, + { + key: 'Smile', + value: '90', + }, + { + key: 'Hug', + value: '88', + }, + { + key: 'Laugh', + value: '70', + }, + { + key: 'Heart', + value: '61', + }, + { + key: 'Warm', + value: '74', + }, + { + key: 'Good', + value: '89', + }, + { + key: 'Thanks', + value: '59', + }, + { + key: 'Share', + value: '57', + }, + { + key: 'Brave', + value: '92', + }, + { + key: 'Grow', + value: '40', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'sVew7tk6GGRcTXGvy5tmZnAHaF3HWgTSoNDuenTKKDS', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'sVew7tk6GGRcTXGvy5tmZnAHaF3HWgTSoNDuenTKKDS', + token_id: null, + token_standard: 'NFT', + name: 'Rehemu Umber 68', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/YiJbXaBzCIXNwbtwYbCDLp76V02YzXiBocjfGABGcrE', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '68', + }, + { + key: 'Smile', + value: '86', + }, + { + key: 'Hug', + value: '69', + }, + { + key: 'Laugh', + value: '64', + }, + { + key: 'Heart', + value: '72', + }, + { + key: 'Warm', + value: '61', + }, + { + key: 'Good', + value: '58', + }, + { + key: 'Thanks', + value: '66', + }, + { + key: 'Share', + value: '84', + }, + { + key: 'Brave', + value: '27', + }, + { + key: 'Grow', + value: '93', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5JZ3JySwmA8hYwKvtyRJHV7Bx7hVb7X3xz4jwcfJvoKE', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5JZ3JySwmA8hYwKvtyRJHV7Bx7hVb7X3xz4jwcfJvoKE', + token_id: null, + token_standard: 'NFT', + name: 'Shihawo Maize 10', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/_pCz0o6WCPeFlx9cPkJCKPRczc4jAmns7HgBjlHwOAs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Maize', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '10', + }, + { + key: 'Smile', + value: '15', + }, + { + key: 'Hug', + value: '1', + }, + { + key: 'Laugh', + value: '14', + }, + { + key: 'Heart', + value: '7', + }, + { + key: 'Warm', + value: '12', + }, + { + key: 'Good', + value: '2', + }, + { + key: 'Thanks', + value: '14', + }, + { + key: 'Share', + value: '18', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '16', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '9dHS9jmB75yZhPevY7rpdVGCakLMREzvW87kvzjFPMZC', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '9dHS9jmB75yZhPevY7rpdVGCakLMREzvW87kvzjFPMZC', + token_id: null, + token_standard: 'NFT', + name: 'Muchinu Copper 17', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/RaNQBwiovSKFLEAa4R12caIMluHR9i_nCMlbkZwZafo', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '17', + }, + { + key: 'Smile', + value: '6', + }, + { + key: 'Hug', + value: '37', + }, + { + key: 'Laugh', + value: '1', + }, + { + key: 'Heart', + value: '21', + }, + { + key: 'Warm', + value: '2', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '32', + }, + { + key: 'Share', + value: '15', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '54', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BL4gnVWq6p91BTCgpae8Mz8TMPnz6mAdrwm7Mjmp7g8L', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BL4gnVWq6p91BTCgpae8Mz8TMPnz6mAdrwm7Mjmp7g8L', + token_id: null, + token_standard: 'NFT', + name: 'Neroka Nostalgia 95', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/AirpCII2848n3dAjRxeb9jAq0cTin4a44OFu1k6aKo8', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime Sky Violet', + }, + { + key: 'Background', + value: 'Nostalgia', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '95', + }, + { + key: 'Smile', + value: '100', + }, + { + key: 'Hug', + value: '100', + }, + { + key: 'Laugh', + value: '95', + }, + { + key: 'Heart', + value: '100', + }, + { + key: 'Warm', + value: '100', + }, + { + key: 'Good', + value: '93', + }, + { + key: 'Thanks', + value: '100', + }, + { + key: 'Share', + value: '93', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '69', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '3XzVvHQ4nddxeHidrVUDHGjk7qmmGQmgWYrK1evZL6SV', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '3XzVvHQ4nddxeHidrVUDHGjk7qmmGQmgWYrK1evZL6SV', + token_id: null, + token_standard: 'NFT', + name: 'Esun Moss 47', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreiddoosg4przrxud5ztald37gabo7a4hqfelt4d7bh65jji42rsjvm', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Moss', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '47', + }, + { + key: 'Smile', + value: '41', + }, + { + key: 'Hug', + value: '66', + }, + { + key: 'Laugh', + value: '45', + }, + { + key: 'Heart', + value: '61', + }, + { + key: 'Warm', + value: '51', + }, + { + key: 'Good', + value: '53', + }, + { + key: 'Thanks', + value: '29', + }, + { + key: 'Share', + value: '39', + }, + { + key: 'Brave', + value: '49', + }, + { + key: 'Grow', + value: '36', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GVbaA4GqqceibdNjPaSsVccsYJtKk7ZPZD4ET9UZgpV4', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GVbaA4GqqceibdNjPaSsVccsYJtKk7ZPZD4ET9UZgpV4', + token_id: null, + token_standard: 'NFT', + name: 'Rokane Red 98', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Vcv9OmO7rH_7hhqPSe037jFUnCwYCUdVRDpl2LI6pDw', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Red', + }, + { + key: 'Rarity', + value: 'Legendary', + }, + { + key: 'Rating', + value: '98', + }, + { + key: 'Smile', + value: '98', + }, + { + key: 'Hug', + value: '92', + }, + { + key: 'Laugh', + value: '98', + }, + { + key: 'Heart', + value: '91', + }, + { + key: 'Warm', + value: '93', + }, + { + key: 'Good', + value: '91', + }, + { + key: 'Thanks', + value: '100', + }, + { + key: 'Share', + value: '98', + }, + { + key: 'Brave', + value: '96', + }, + { + key: 'Grow', + value: '90', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '6SaWmPz9KUj2ahSVSc6gkeED4jSEGHYL6VsbB89uYre', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '6SaWmPz9KUj2ahSVSc6gkeED4jSEGHYL6VsbB89uYre', + token_id: null, + token_standard: 'NFT', + name: 'Iheya Artic 87', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/BcOXu57leXYNFOrmDDyQqyXngaLze8eNHKELmDyUaYs', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe Violet Mint', + }, + { + key: 'Background', + value: 'Artic', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '87', + }, + { + key: 'Smile', + value: '87', + }, + { + key: 'Hug', + value: '97', + }, + { + key: 'Laugh', + value: '89', + }, + { + key: 'Heart', + value: '84', + }, + { + key: 'Warm', + value: '90', + }, + { + key: 'Good', + value: '77', + }, + { + key: 'Thanks', + value: '85', + }, + { + key: 'Share', + value: '86', + }, + { + key: 'Brave', + value: '95', + }, + { + key: 'Grow', + value: '80', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2JpbZQRbKQypRj8aMCcRGNsHs1Af9ei9vJ8QYeLc5VZZ', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2JpbZQRbKQypRj8aMCcRGNsHs1Af9ei9vJ8QYeLc5VZZ', + token_id: null, + token_standard: 'NFT', + name: 'Onono Fantasy 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/6_42us2fNsf0b8NfP936MSt-yD2ZcTJGeVH4Oma7z6s', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Rose Lime Sky', + }, + { + key: 'Background', + value: 'Fantasy', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '86', + }, + { + key: 'Smile', + value: '81', + }, + { + key: 'Hug', + value: '82', + }, + { + key: 'Laugh', + value: '90', + }, + { + key: 'Heart', + value: '94', + }, + { + key: 'Warm', + value: '96', + }, + { + key: 'Good', + value: '81', + }, + { + key: 'Thanks', + value: '95', + }, + { + key: 'Share', + value: '92', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '49', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '7ZBAFmT9Tq9ftw6PBQQfb9Ks3FR9Pd1QZZTVvCkvBbEZ', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '7ZBAFmT9Tq9ftw6PBQQfb9Ks3FR9Pd1QZZTVvCkvBbEZ', + token_id: null, + token_standard: 'NFT', + name: 'Minuna Crimson 35', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreibpcafvzlmtrsgd6idgnjut6wfqpf7cnx5r4bhalpxikmiedwtkoa', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Tangerine', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '35', + }, + { + key: 'Smile', + value: '31', + }, + { + key: 'Hug', + value: '38', + }, + { + key: 'Laugh', + value: '20', + }, + { + key: 'Heart', + value: '53', + }, + { + key: 'Warm', + value: '20', + }, + { + key: 'Good', + value: '52', + }, + { + key: 'Thanks', + value: '28', + }, + { + key: 'Share', + value: '52', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '55', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'CmQrWkn7BMMh6oSXvQaavRsYNNED7odJNdWApPv8cpJs', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'CmQrWkn7BMMh6oSXvQaavRsYNNED7odJNdWApPv8cpJs', + token_id: null, + token_standard: 'NFT', + name: 'Kukime Lilac 79', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/zaMpFeeG8tXUU0r-VwaDga7s7SVszNaIDuK19N1sS-M', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Lilac', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '79', + }, + { + key: 'Smile', + value: '82', + }, + { + key: 'Hug', + value: '73', + }, + { + key: 'Laugh', + value: '75', + }, + { + key: 'Heart', + value: '74', + }, + { + key: 'Warm', + value: '71', + }, + { + key: 'Good', + value: '76', + }, + { + key: 'Thanks', + value: '85', + }, + { + key: 'Share', + value: '74', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '80', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'HqR3AeQe8DdEmrwx9WDHgFKtFk14vsyZiehaw7jYqCN4', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'HqR3AeQe8DdEmrwx9WDHgFKtFk14vsyZiehaw7jYqCN4', + token_id: null, + token_standard: 'NFT', + name: 'Hosake Copper 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreifm5zxmd4fx6fycr2skxan565nzk5i73y5urcqxbuuyjsq3i5ljly', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Blaze', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '30', + }, + { + key: 'Smile', + value: '46', + }, + { + key: 'Hug', + value: '39', + }, + { + key: 'Laugh', + value: '13', + }, + { + key: 'Heart', + value: '27', + }, + { + key: 'Warm', + value: '21', + }, + { + key: 'Good', + value: '26', + }, + { + key: 'Thanks', + value: '10', + }, + { + key: 'Share', + value: '26', + }, + { + key: 'Brave', + value: '64', + }, + { + key: 'Grow', + value: '28', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BDnLCAcai1j8oRXWXBGAfMCDsFSMZLZrT1zDxBP2A65K', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BDnLCAcai1j8oRXWXBGAfMCDsFSMZLZrT1zDxBP2A65K', + token_id: null, + token_standard: 'NFT', + name: 'Mumani Crimson 17', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/_qiLOsq812UBzz6ZVJtLH5avgdxNpl494yIoNbIED8M', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Gold', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '17', + }, + { + key: 'Smile', + value: '17', + }, + { + key: 'Hug', + value: '17', + }, + { + key: 'Laugh', + value: '18', + }, + { + key: 'Heart', + value: '1', + }, + { + key: 'Warm', + value: '33', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '8', + }, + { + key: 'Share', + value: '23', + }, + { + key: 'Brave', + value: '46', + }, + { + key: 'Grow', + value: '6', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'J2V4EVhC7J1zm5gGPYo5WqMGsJAPvSon2kjDBaYZ5CEv', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'J2V4EVhC7J1zm5gGPYo5WqMGsJAPvSon2kjDBaYZ5CEv', + token_id: null, + token_standard: 'NFT', + name: 'Inaro Slate 60', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/l1Vpj5W5xEmERyos7uuCWRj1oe7kUoVQslbuouz0Yzg', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Violet', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '60', + }, + { + key: 'Smile', + value: '63', + }, + { + key: 'Hug', + value: '48', + }, + { + key: 'Laugh', + value: '46', + }, + { + key: 'Heart', + value: '44', + }, + { + key: 'Warm', + value: '76', + }, + { + key: 'Good', + value: '77', + }, + { + key: 'Thanks', + value: '45', + }, + { + key: 'Share', + value: '47', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '54', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5CRJGB6hyjZ9JUXK1qu8btGuJH8U56q4tenKmU7noj6H', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5CRJGB6hyjZ9JUXK1qu8btGuJH8U56q4tenKmU7noj6H', + token_id: null, + token_standard: 'NFT', + name: 'Sanaya Slate 74', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/P_R0j05mMT1HSD9zKZaTj_vlQVAO_3TohZgY321obTI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Slate', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '74', + }, + { + key: 'Smile', + value: '82', + }, + { + key: 'Hug', + value: '85', + }, + { + key: 'Laugh', + value: '64', + }, + { + key: 'Heart', + value: '60', + }, + { + key: 'Warm', + value: '91', + }, + { + key: 'Good', + value: '79', + }, + { + key: 'Thanks', + value: '85', + }, + { + key: 'Share', + value: '57', + }, + { + key: 'Brave', + value: '67', + }, + { + key: 'Grow', + value: '70', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'BqgsdJQemGCuy9e7dcxmHnBnGDTfuugs6Eaw13exyxAC', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'BqgsdJQemGCuy9e7dcxmHnBnGDTfuugs6Eaw13exyxAC', + token_id: null, + token_standard: 'NFT', + name: 'Kimike Moss 64', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreidv72x37cbinkvabz6pubwof7euye46lc7bzao4azk63wiu4v7qjy', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Blaze', + }, + { + key: 'Background', + value: 'Moss', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '64', + }, + { + key: 'Smile', + value: '81', + }, + { + key: 'Hug', + value: '47', + }, + { + key: 'Laugh', + value: '50', + }, + { + key: 'Heart', + value: '65', + }, + { + key: 'Warm', + value: '68', + }, + { + key: 'Good', + value: '81', + }, + { + key: 'Thanks', + value: '45', + }, + { + key: 'Share', + value: '55', + }, + { + key: 'Brave', + value: '60', + }, + { + key: 'Grow', + value: '88', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4iwQrtmh91iMBvAtiEzNHd1cv1QiajbT9dG919ACbFtm', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4iwQrtmh91iMBvAtiEzNHd1cv1QiajbT9dG919ACbFtm', + token_id: null, + token_standard: 'NFT', + name: 'Ramure Umber 5', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/mFWJGX924Dy-FxbLsDslsT7pwuK8D9opTFhZi2HJUKE', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Umber', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '5', + }, + { + key: 'Smile', + value: '1', + }, + { + key: 'Hug', + value: '1', + }, + { + key: 'Laugh', + value: '15', + }, + { + key: 'Heart', + value: '4', + }, + { + key: 'Warm', + value: '1', + }, + { + key: 'Good', + value: '1', + }, + { + key: 'Thanks', + value: '1', + }, + { + key: 'Share', + value: '1', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '24', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5NWin7cHMBUtTjGv9D8fwZwyxvrH6T4uEdK18CuYrjGQ', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5NWin7cHMBUtTjGv9D8fwZwyxvrH6T4uEdK18CuYrjGQ', + token_id: null, + token_standard: 'NFT', + name: 'Toruha Blush 25', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Qe_StXHAExNaOk-t-oRUqQ9KoibTWrEhXsgeQjz5ovk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '25', + }, + { + key: 'Smile', + value: '30', + }, + { + key: 'Hug', + value: '14', + }, + { + key: 'Laugh', + value: '40', + }, + { + key: 'Heart', + value: '25', + }, + { + key: 'Warm', + value: '16', + }, + { + key: 'Good', + value: '32', + }, + { + key: 'Thanks', + value: '11', + }, + { + key: 'Share', + value: '22', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '59', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'A2kRbc9PErBhfj5bRW47ji8cEJF1Fm23xo9qTJzUPuAi', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'A2kRbc9PErBhfj5bRW47ji8cEJF1Fm23xo9qTJzUPuAi', + token_id: null, + token_standard: 'NFT', + name: 'Yanuhi Lilac 30', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://w3s.link/ipfs/bafkreiehytzikifiilewin6rgitkfifyrle2tr6donbad4jnkjsgvlzscm', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Lilac', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '30', + }, + { + key: 'Smile', + value: '47', + }, + { + key: 'Hug', + value: '10', + }, + { + key: 'Laugh', + value: '41', + }, + { + key: 'Heart', + value: '32', + }, + { + key: 'Warm', + value: '23', + }, + { + key: 'Good', + value: '29', + }, + { + key: 'Thanks', + value: '44', + }, + { + key: 'Share', + value: '45', + }, + { + key: 'Brave', + value: '1', + }, + { + key: 'Grow', + value: '28', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GnSLjLgDpP1gPGnMsQQ9g1t5gWn1FiaYJRGsDH1jLvrb', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GnSLjLgDpP1gPGnMsQQ9g1t5gWn1FiaYJRGsDH1jLvrb', + token_id: null, + token_standard: 'NFT', + name: 'Tefute Lilac 38', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/u52d29x9EnRVFzMnj8P5FSCVZqfKykylPPiL6WSTETI', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky', + }, + { + key: 'Background', + value: 'Lilac', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '38', + }, + { + key: 'Smile', + value: '36', + }, + { + key: 'Hug', + value: '41', + }, + { + key: 'Laugh', + value: '58', + }, + { + key: 'Heart', + value: '24', + }, + { + key: 'Warm', + value: '56', + }, + { + key: 'Good', + value: '58', + }, + { + key: 'Thanks', + value: '22', + }, + { + key: 'Share', + value: '18', + }, + { + key: 'Brave', + value: '47', + }, + { + key: 'Grow', + value: '20', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4C3yLSBWzE8BoEcbYCrDYZG6DPPa2JiUJDaK7DuP5waw', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4C3yLSBWzE8BoEcbYCrDYZG6DPPa2JiUJDaK7DuP5waw', + token_id: null, + token_standard: 'NFT', + name: 'Waria Fantasy 86', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/VXjz_9IOmrxFpGATG2YBe4UblSo7rEYedBhv89tAc0Y', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Mint Stone Gold', + }, + { + key: 'Background', + value: 'Fantasy', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '86', + }, + { + key: 'Smile', + value: '91', + }, + { + key: 'Hug', + value: '79', + }, + { + key: 'Laugh', + value: '90', + }, + { + key: 'Heart', + value: '85', + }, + { + key: 'Warm', + value: '82', + }, + { + key: 'Good', + value: '88', + }, + { + key: 'Thanks', + value: '81', + }, + { + key: 'Share', + value: '86', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '78', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '4sBK1uNN4H2BdjXgYnnU9UrVLR5N5gHfcaTwpMp4ZchR', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '4sBK1uNN4H2BdjXgYnnU9UrVLR5N5gHfcaTwpMp4ZchR', + token_id: null, + token_standard: 'NFT', + name: 'Yushini Crimson 58', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/kN2l0ydJRhKMRZRGBaBTP6YeV_tcRWPNtV-UNLlqNZ4', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '58', + }, + { + key: 'Smile', + value: '39', + }, + { + key: 'Hug', + value: '47', + }, + { + key: 'Laugh', + value: '44', + }, + { + key: 'Heart', + value: '41', + }, + { + key: 'Warm', + value: '76', + }, + { + key: 'Good', + value: '65', + }, + { + key: 'Thanks', + value: '56', + }, + { + key: 'Share', + value: '73', + }, + { + key: 'Brave', + value: '54', + }, + { + key: 'Grow', + value: '85', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'HoqygdHmhszACPdKz49MWCiM9gpRewsb2BxN7WFbVZ8g', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'HoqygdHmhszACPdKz49MWCiM9gpRewsb2BxN7WFbVZ8g', + token_id: null, + token_standard: 'NFT', + name: 'Temimi Volcano 91', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/GKGnHgLTaEWfhoWO7ZJsC_8a7xaSiEO9eSC3QmBnRUg', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Taupe Tangerine Sky', + }, + { + key: 'Background', + value: 'Volcano', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '91', + }, + { + key: 'Smile', + value: '89', + }, + { + key: 'Hug', + value: '91', + }, + { + key: 'Laugh', + value: '84', + }, + { + key: 'Heart', + value: '98', + }, + { + key: 'Warm', + value: '100', + }, + { + key: 'Good', + value: '90', + }, + { + key: 'Thanks', + value: '87', + }, + { + key: 'Share', + value: '89', + }, + { + key: 'Brave', + value: '95', + }, + { + key: 'Grow', + value: '87', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'TzuLBLZQz34QuMGhBWfagW3CX7MRgbnX9TGvgMq7Er7', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'TzuLBLZQz34QuMGhBWfagW3CX7MRgbnX9TGvgMq7Er7', + token_id: null, + token_standard: 'NFT', + name: 'Reose Azure 71', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/mMsD_yGDrzPsQn9Qlzvj_zOCAGvkLleTJbXdzavzg0g', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Azure', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '71', + }, + { + key: 'Smile', + value: '90', + }, + { + key: 'Hug', + value: '76', + }, + { + key: 'Laugh', + value: '53', + }, + { + key: 'Heart', + value: '74', + }, + { + key: 'Warm', + value: '80', + }, + { + key: 'Good', + value: '84', + }, + { + key: 'Thanks', + value: '84', + }, + { + key: 'Share', + value: '86', + }, + { + key: 'Brave', + value: '80', + }, + { + key: 'Grow', + value: '3', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '2gqJ1hBKNAz4Zp4N3jtNNyNMgPoiVELJ39dzNscAwrMS', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '2gqJ1hBKNAz4Zp4N3jtNNyNMgPoiVELJ39dzNscAwrMS', + token_id: null, + token_standard: 'NFT', + name: 'Nenesu Blush 20', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/J57aqt1TXeadJFdCmhaPPG9l2rMWdC4gPzt-28jAmcc', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Blush', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '20', + }, + { + key: 'Smile', + value: '30', + }, + { + key: 'Hug', + value: '16', + }, + { + key: 'Laugh', + value: '7', + }, + { + key: 'Heart', + value: '19', + }, + { + key: 'Warm', + value: '18', + }, + { + key: 'Good', + value: '16', + }, + { + key: 'Thanks', + value: '24', + }, + { + key: 'Share', + value: '36', + }, + { + key: 'Brave', + value: '13', + }, + { + key: 'Grow', + value: '21', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'AempPeKEMVyKoopMSLgTJ8uhgxXAQSeGKkN6Bxg59Sjv', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'AempPeKEMVyKoopMSLgTJ8uhgxXAQSeGKkN6Bxg59Sjv', + token_id: null, + token_standard: 'NFT', + name: 'Nuteki Crimson 18', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/YVk50ScKfjw_Ge7jzH7AvCBFY-asGWkiJiALH0l1WFU', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone', + }, + { + key: 'Background', + value: 'Crimson', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '18', + }, + { + key: 'Smile', + value: '2', + }, + { + key: 'Hug', + value: '25', + }, + { + key: 'Laugh', + value: '13', + }, + { + key: 'Heart', + value: '15', + }, + { + key: 'Warm', + value: '28', + }, + { + key: 'Good', + value: '17', + }, + { + key: 'Thanks', + value: '1', + }, + { + key: 'Share', + value: '17', + }, + { + key: 'Brave', + value: '47', + }, + { + key: 'Grow', + value: '15', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'FJjbsmyDUiSWqnTScUVPJ7gtkgWLu8eLNE2ZSZQhWH4o', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'FJjbsmyDUiSWqnTScUVPJ7gtkgWLu8eLNE2ZSZQhWH4o', + token_id: null, + token_standard: 'NFT', + name: 'Tooku Fantasy 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/TbZlusywCg4INdEwAv4Q-Fz00bZjyRGaqrwCFlw0uUc', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Sky Stone Lime', + }, + { + key: 'Background', + value: 'Fantasy', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '89', + }, + { + key: 'Smile', + value: '98', + }, + { + key: 'Hug', + value: '89', + }, + { + key: 'Laugh', + value: '97', + }, + { + key: 'Heart', + value: '88', + }, + { + key: 'Warm', + value: '89', + }, + { + key: 'Good', + value: '99', + }, + { + key: 'Thanks', + value: '81', + }, + { + key: 'Share', + value: '87', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '62', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'GPCD4HbAgm4sYAEeChCTr6cvnQFqpCV1Hb4YqtUBFMLs', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'GPCD4HbAgm4sYAEeChCTr6cvnQFqpCV1Hb4YqtUBFMLs', + token_id: null, + token_standard: 'NFT', + name: 'Miuse Copper 70', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/Fwub1TdOGg0-tqS6zWs43SVdUj7ppT3eboXXTUExuVQ', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Lime', + }, + { + key: 'Background', + value: 'Copper', + }, + { + key: 'Rarity', + value: 'Common', + }, + { + key: 'Rating', + value: '70', + }, + { + key: 'Smile', + value: '66', + }, + { + key: 'Hug', + value: '89', + }, + { + key: 'Laugh', + value: '61', + }, + { + key: 'Heart', + value: '51', + }, + { + key: 'Warm', + value: '63', + }, + { + key: 'Good', + value: '53', + }, + { + key: 'Thanks', + value: '76', + }, + { + key: 'Share', + value: '67', + }, + { + key: 'Brave', + value: '86', + }, + { + key: 'Grow', + value: '88', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: '5f8JwHZdGVMzb3juDgpPT1hnYD18MofKv1sEjxghRDGH', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: '5f8JwHZdGVMzb3juDgpPT1hnYD18MofKv1sEjxghRDGH', + token_id: null, + token_standard: 'NFT', + name: 'Nechiwa Desert 89', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/vWTByzWVm_Gln-qyKU6Ajy3eWcZkdRs0BUZu6-hjrjE', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Violet Tangerine Butter', + }, + { + key: 'Background', + value: 'Desert', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '89', + }, + { + key: 'Smile', + value: '84', + }, + { + key: 'Hug', + value: '80', + }, + { + key: 'Laugh', + value: '95', + }, + { + key: 'Heart', + value: '94', + }, + { + key: 'Warm', + value: '84', + }, + { + key: 'Good', + value: '82', + }, + { + key: 'Thanks', + value: '95', + }, + { + key: 'Share', + value: '95', + }, + { + key: 'Brave', + value: '94', + }, + { + key: 'Grow', + value: '87', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + { + chain: 'solana', + address: '91GBiM6pBxVsANodJd4nGjnKBDE8XRJbcvQD9C1Phpyb', + token_address: 'eUyEuzbhePsioreWqYsJ82KtLpgTDLwVkAvLxQqBTKV', + token_id: null, + balance: 1, + acquired_at: null, + nft_token: { + address: 'eUyEuzbhePsioreWqYsJ82KtLpgTDLwVkAvLxQqBTKV', + token_id: null, + token_standard: 'NFT', + name: 'Nosuru Fantasy 90', + description: + 'MONMONMON is a collection from the creativity of Peelander Yellow. Each MONMONMON has unique and kind abilities that can be used to help others and play with your friends. There are secrets in each MONMONMON. We love you.', + metadata: null, + image_url: + 'https://gateway.irys.xyz/31LAzQj901LQO_hBh4EHy0H739AuqPw2lmbRGzIzqlk', + media_url: null, + external_url: 'https://www.monmonmon.art/', + attributes: [ + { + key: 'Body', + value: 'Stone Sky Tangerine', + }, + { + key: 'Background', + value: 'Fantasy', + }, + { + key: 'Rarity', + value: 'Rare', + }, + { + key: 'Rating', + value: '90', + }, + { + key: 'Smile', + value: '95', + }, + { + key: 'Hug', + value: '98', + }, + { + key: 'Laugh', + value: '88', + }, + { + key: 'Heart', + value: '93', + }, + { + key: 'Warm', + value: '92', + }, + { + key: 'Good', + value: '93', + }, + { + key: 'Thanks', + value: '93', + }, + { + key: 'Share', + value: '81', + }, + { + key: 'Brave', + value: '100', + }, + { + key: 'Grow', + value: '67', + }, + ], + token_account_address: null, + creators: [ + { + address: '3pXaiqHCosfWfaSqEqPH3nKQKRrcMphR7D7ycRg3LQJs', + share: 20, + verified: 0, + }, + { + address: '6LVnHwYhwiCjtdKYyJgtDUdfMgFwqZG917Ct1WuaTZgC', + share: 50, + verified: 0, + }, + { + address: 'CU77oRiiTjGQuYz4Rjcs8NRocM2dunBxCuuA5eQFgxHK', + share: 30, + verified: 0, + }, + ], + collection_name: 'MONMONMON HOLA', + collection_symbol: 'hola_monmonmon', + collection_count: 888659, + collection_image_url: null, + onchain_collection_address: + '2WRn18ZiHji4ww3bsn2RUE2DDy2V45jChCR5mZHkbt6N', + floor_price: null, + last_sale_price: null, + rarity: null, + }, + isSpam: false, + }, + ], + }; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/nft-api/types.ts b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/types.ts new file mode 100644 index 00000000..083a8769 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/nft-api/types.ts @@ -0,0 +1,170 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type PaginatedResponse = { + cursor: string | null; + error: string | null; + items: Type[]; +}; + +/** + * Raw NFT API responses + */ + +export type NftApiItemType = { + address: string; + token_id: string | null; + token_standard: string | null; + name: string; + description: string; + metadata: string | null; + image_url: string; + media_url: string | null; + external_url: string; + attributes: { + key: string; + value: string; + }[]; + token_account_address: string | null; + creators: { + address: string; + share: number; + verified: number; + }[]; + collection_name: string; + collection_symbol: string; + collection_count: number; + collection_image_url: string | null; + onchain_collection_address: string | null; + floor_price: { + asset: { + type: string; + name: string; + symbol: string; + decimals: number; + token_id: string; + }; + amount: { + raw_amount: string; + amount: number; + }; + } | null; + last_sale_price: { + asset: { + type: string; + name: string; + symbol: string; + decimals: number; + token_id: string; + }; + amount: { + raw_amount: string; + amount: number; + }; + } | null; + rarity: { + ranking: { + source: string; + value: number; + }; + metadata: { + howrare: { + rank: number; + }; + moonrank: { + rank: number; + }; + }; + } | null; +}; + +export type NftApiBalanceType = { + chain: string; + address: string; + token_address: string; + token_id: string | null; + balance: number; + acquired_at: string | null; + isSpam: boolean; + nft_token: NftApiItemType; +}; + +/** + * Mapped NFT API responses + */ + +export type Nft = { + address: string; + tokenId: string | null; + tokenStandard: string | null; + name: string; + description: string; + metadata: string | null; + imageUrl: string; + mediaUrl: string | null; + externalUrl: string; + attributes: { + key: string; + value: string; + }[]; + tokenAccountAddress: string | null; + creators: { + address: string; + share: number; + verified: number; + }[]; + collectionName: string; + collectionSymbol: string; + collectionCount: number; + collectionImageUrl: string | null; + onchainCollectionAddress: string | null; + floorPrice: { + asset: { + type: string; + name: string; + symbol: string; + decimals: number; + tokenId: string; + }; + amount: { + rawAmount: string; + amount: number; + }; + } | null; + lastSalePrice: { + asset: { + type: string; + name: string; + symbol: string; + decimals: number; + tokenId: string; + }; + amount: { + rawAmount: string; + amount: number; + }; + } | null; + rarity: { + ranking: { + source: string; + value: number; + }; + metadata: { + howrare: { + rank: number; + }; + moonrank: { + rank: number; + }; + }; + } | null; +}; + +export type Balance = { + chain: string; + address: string; + tokenAddress: string; + tokenId: string | null; + balance: number; + acquiredAt: string | null; + isSpam: boolean; + nftToken: Nft; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts new file mode 100644 index 00000000..ac5ec77e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts @@ -0,0 +1,400 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CaipAssetType } from '@metamask/keyring-api'; +import { cloneDeep } from 'lodash'; + +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { KnownCaip19Id } from '../../constants/solana'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../services/mocks/logger'; +import { MOCK_EXCHANGE_RATES } from '../../test/mocks/price-api/exchange-rates'; +import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; +import { MOCK_SPOT_PRICES } from './mocks/spot-prices'; +import { PriceApiClient } from './PriceApiClient'; +import type { SpotPrices, VsCurrencyParam } from './types'; + +describe('PriceApiClient', () => { + let mockFetch: jest.Mock; + let mockCache: ICache; + let client: PriceApiClient; + + beforeEach(() => { + mockFetch = jest.fn(); + + const mockConfigProvider: ConfigProvider = { + get: jest.fn().mockReturnValue({ + priceApi: { + baseUrl: 'https://some-mock-url.com', + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: 0, + spotPrices: 0, + historicalPrices: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + mockCache = new InMemoryCache(mockLogger); + + client = new PriceApiClient( + mockConfigProvider, + mockCache, + mockFetch, + mockLogger, + ); + }); + + describe('getFiatExchangeRates', () => { + it('fetches fiat exchange rates successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_EXCHANGE_RATES), + }); + + const result = await client.getFiatExchangeRates(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v1/exchange-rates/fiat', + ); + expect(result).toStrictEqual(MOCK_EXCHANGE_RATES); + }); + + it('caches the fiat exchange rates', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_EXCHANGE_RATES), + }); + + // Call a first time. It populates the cache + await client.getFiatExchangeRates(); + + // Call a second time. It should use the cache + await client.getFiatExchangeRates(); + + // Fetch should only be called once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('getMultipleSpotPrices', () => { + const mockResponse: SpotPrices = { + [KnownCaip19Id.SolMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + [KnownCaip19Id.UsdcMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }; + + describe('when the data is not cached', () => { + it('fetches multiple spot prices successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('logs and throws an error if fetch fails', async () => { + const mockError = new Error('Fetch failed'); + mockFetch.mockRejectedValueOnce(mockError); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Fetch failed'); + expect(mockLogger.error).toHaveBeenCalledWith( + mockError, + 'Error fetching spot prices', + ); + }); + + it('throws an error if response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('HTTP error! status: 404'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('fetches spot price with custom vsCurrency', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_SPOT_PRICES), + }); + + await client.getMultipleSpotPrices( + [KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet], + 'eur', + ); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=eur&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + }); + + it('handles malformed JSON response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValueOnce(new Error('Invalid JSON')), + }); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Invalid JSON'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('handles network timeout', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Network timeout'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('throws when malformed response from the Price API', async () => { + const mockMalformedResponse = cloneDeep(mockResponse); + mockMalformedResponse[KnownCaip19Id.SolMainnet]!.price = -999; // Price must be a positive number + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockMalformedResponse), + }); + + await expect( + client.getMultipleSpotPrices([KnownCaip19Id.SolMainnet]), + ).rejects.toThrow( + 'At path: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501.price -- Expected a number greater than or equal to 0 but received `-999`', + ); + }); + }); + + describe('when the data is fully cached', () => { + it('returns the cached data', async () => { + const cachedData = { + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }; + jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); + jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockCache.mset).not.toHaveBeenCalled(); + }); + }); + + describe('when the data is partially cached', () => { + it('returns the cached data', async () => { + // Only the first token is cached, we will need to fetch the second one + const cachedData = { + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + }; + jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); + jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + [KnownCaip19Id.UsdcMainnet]: + MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }), + }); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(result).toStrictEqual(mockResponse); + + // We should have fetched the second token only + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + + // The second token should be added to the cache + expect(mockCache.mset).toHaveBeenCalledWith([ + { + key: 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd', + value: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + ttlMilliseconds: 0, + }, + ]); + }); + }); + }); + + describe('security', () => { + it('rejects invalid base URLs in constructor', () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + priceApi: { + baseUrl: 'invalid-url', + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: 0, + spotPrices: 0, + historicalPrices: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + expect( + () => + new PriceApiClient( + invalidConfigProvider, + mockCache, + mockFetch, + mockLogger, + ), + ).toThrow('Invalid URL format'); + }); + + it('rejects tokenCaipAssetTypes that are invalid or that include malicious inputs', async () => { + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + 'INVALID' as CaipAssetType, + ]), + ).rejects.toThrow( + 'At path: 1 -- Expected a value of type `CaipAssetType`, but received: `"INVALID"`', + ); + }); + + it('rejects vsCurrency parameters that are invalid or that include malicious inputs', async () => { + await expect( + client.getMultipleSpotPrices( + [KnownCaip19Id.SolLocalnet], + 'INVALID', + ), + ).rejects.toThrow(/Expected/u); + }); + + it('handles URLs with multiple query parameters safely', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({}), + }); + + await client.getMultipleSpotPrices([KnownCaip19Id.SolLocalnet]); + + // Verify URL is properly constructed with encoded parameters + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/some-mock-url\.com\/v3\/spot-prices\?([^&=]+=[^&]*&)*[^&=]+=.+$/u, + ), + ); + }); + + it('rejects non-printable characters in input', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({}), + }); + + await expect( + client.getMultipleSpotPrices( + [KnownCaip19Id.SolLocalnet], + 'usd\x00\x1F', + ), + ).rejects.toThrow(/Expected/u); + }); + }); + + describe('getHistoricalPrices', () => { + describe('when the data is not cached', () => { + it('fetches historical prices successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_HISTORICAL_PRICES), + }); + + const cacheSetSpy = jest.spyOn(mockCache, 'set'); + + const result = await client.getHistoricalPrices({ + assetType: KnownCaip19Id.SolMainnet, + timePeriod: '5d', + from: 123, + to: 456, + vsCurrency: 'usd', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/historical-prices/solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501?timePeriod=5d&from=123&to=456&vsCurrency=usd', + ); + expect(cacheSetSpy).toHaveBeenCalledWith( + 'PriceApiClient:getHistoricalPrices:{"assetType":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501","timePeriod":"5d","from":123,"to":456,"vsCurrency":"usd"}', + MOCK_HISTORICAL_PRICES, + 0, + ); + expect(result).toStrictEqual(MOCK_HISTORICAL_PRICES); + }); + }); + + describe('when the data is cached', () => { + it('returns the cached data', async () => { + jest + .spyOn(mockCache, 'get') + .mockResolvedValueOnce(MOCK_HISTORICAL_PRICES); + + const cacheGetSpy = jest.spyOn(mockCache, 'get'); + const cacheSetSpy = jest.spyOn(mockCache, 'set'); + + const result = await client.getHistoricalPrices({ + assetType: KnownCaip19Id.SolMainnet, + timePeriod: '5d', + from: 123, + to: 456, + vsCurrency: 'usd', + }); + + expect(cacheGetSpy).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toStrictEqual(MOCK_HISTORICAL_PRICES); + expect(cacheSetSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts new file mode 100644 index 00000000..70e592ad --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts @@ -0,0 +1,334 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { CaipAssetType } from '@metamask/keyring-api'; +import { array, assert } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import { mapKeys } from 'lodash'; + +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import { UrlStruct } from '../../validation/structs'; +import type { + ExchangeRate, + FiatTicker, + GetHistoricalPricesParams, + GetHistoricalPricesResponse, + SpotPrices, + VsCurrencyParam, +} from './types'; +import { + GetHistoricalPricesParamsStruct, + GetHistoricalPricesResponseStruct, + SpotPricesStruct, + VsCurrencyParamStruct, +} from './types'; + +export class PriceApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + readonly #cache: ICache; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + + constructor( + configProvider: ConfigProvider, + _cache: ICache, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + const { baseUrl, chunkSize, cacheTtlsMilliseconds } = + configProvider.get().priceApi; + + assert(baseUrl, UrlStruct); + + this.#fetch = _fetch; + this.#logger = _logger; + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + + this.#cache = _cache; + } + + async getFiatExchangeRates(): Promise> { + return useCache( + this.#getFiatExchangeRates_INTERNAL.bind(this), + this.#cache, + { + functionName: 'PriceApiClient:getFiatExchangeRates', + ttlMilliseconds: this.cacheTtlsMilliseconds.fiatExchangeRates, + }, + )(); + } + + async #getFiatExchangeRates_INTERNAL(): Promise< + Record + > { + try { + const response = await this.#fetch( + `${this.#baseUrl}/v1/exchange-rates/fiat`, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + this.#logger.error(error, 'Error fetching fiat exchange rates'); + throw error; + } + } + + /** + * Business logic for `getMultipleSpotPrices`. + * + * @param tokenCaipAssetTypes - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async #getMultipleSpotPrices_INTERNAL( + tokenCaipAssetTypes: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + try { + assert(tokenCaipAssetTypes, array(CaipAssetTypeStruct)); + assert(vsCurrency, VsCurrencyParamStruct); + + if (tokenCaipAssetTypes.length === 0) { + return {}; + } + + const uniqueTokenCaipAssetTypes = [...new Set(tokenCaipAssetTypes)]; + + // Split uniqueTokenCaipAssetTypes into chunks + const chunks: CaipAssetType[][] = []; + for ( + let i = 0; + i < uniqueTokenCaipAssetTypes.length; + i += this.#chunkSize + ) { + chunks.push(uniqueTokenCaipAssetTypes.slice(i, i + this.#chunkSize)); + } + + // Make parallel requests for each chunk + const responses = await Promise.all( + chunks.map(async (chunk) => { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency, + assetIds: chunk.join(','), + includeMarketData: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const spotPrices = await response.json(); + assert(spotPrices, SpotPricesStruct); + + return spotPrices; + }), + ); + + // Combine all responses + const spotPrices = responses.reduce( + (prices, price) => ({ ...prices, ...price }), + {}, + ); + + // Store in the cache + await this.#cache.mset( + tokenCaipAssetTypes.map((tokenCaipAssetType) => ({ + key: `PriceApiClient:getMultipleSpotPrices:${tokenCaipAssetType}:${vsCurrency}`, + value: spotPrices[tokenCaipAssetType], + ttlMilliseconds: this.cacheTtlsMilliseconds.spotPrices, + })), + ); + + return spotPrices; + } catch (error) { + this.#logger.error(error, 'Error fetching spot prices'); + throw error; + } + } + + /** + * Internal caching logic for `getMultipleSpotPrices`: + * - Uses mget/mset for batch operations. + * - Handles proper cache key management. + * - Handles partial cache hits (fetches only non-cached conversions). + * + * @param tokenCaip19Types - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async #getMultipleSpotPrices_CACHE( + tokenCaip19Types: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + const uniqueTokenCaip19Types = [...new Set(tokenCaip19Types)]; + + const cacheKeyPrefix = 'PriceApiClient:getMultipleSpotPrices'; + + // Shorthand method to generate the cache key + const toCacheKey = (tokenCaipAssetType: CaipAssetType) => + `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; + + // Parses back the cache key + const parseCacheKey = (key: string) => { + const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); + const match = key.match(regex); + + if (!match) { + throw new Error('Invalid cache key'); + } + + return match; + }; + + // Get the cached spot prices + const cachedSpotPricesRecord = await this.#cache.mget( + uniqueTokenCaip19Types.map(toCacheKey), + ); + + // Keys when read from the cache are the cache keys ("PriceApiClient:getMultipleSpotPrices:..."), not the token CAIP-19 IDs, so here we transform them back to the token CAIP-19 types. + const cachedSpotPricesRecordWithParsedKeys = mapKeys( + cachedSpotPricesRecord, + (_, key) => parseCacheKey(key)[1], + ); + + // We still need to fetch the spot prices for the tokens that are not cached + const nonCachedTokenCaip19Types = uniqueTokenCaip19Types.filter( + (tokenCaip19Type) => + cachedSpotPricesRecordWithParsedKeys[tokenCaip19Type] === undefined, + ); + + if (nonCachedTokenCaip19Types.length === 0) { + return cachedSpotPricesRecordWithParsedKeys as SpotPrices; + } + + // Fetch the spot prices for the tokens that are not cached + const nonCachedSpotPrices = await this.#getMultipleSpotPrices_INTERNAL( + nonCachedTokenCaip19Types, + vsCurrency, + ); + + // Cache the data + await this.#cache.mset( + Object.entries(nonCachedSpotPrices).map( + ([tokenCaipAssetType, spotPrice]) => ({ + key: toCacheKey(tokenCaipAssetType as CaipAssetType), + value: spotPrice, + ttlMilliseconds: this.cacheTtlsMilliseconds.spotPrices, + }), + ), + ); + + return { + ...cachedSpotPricesRecordWithParsedKeys, + ...nonCachedSpotPrices, + }; + } + + /** + * Get multiple spot prices for a list of tokens. + * It caches the results for 1 hour. + * + * @param tokenCaip19Types - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async getMultipleSpotPrices( + tokenCaip19Types: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + return this.#getMultipleSpotPrices_CACHE(tokenCaip19Types, vsCurrency); + } + + /** + * Business logic for `getHistoricalPrices`. + * + * @param params - The parameters for the request. + * @param params.assetType - The asset type of the token. + * @param params.timePeriod - The time period for the historical prices. + * @param params.from - The start date for the historical prices. + * @param params.to - The end date for the historical prices. + * @param params.vsCurrency - The currency to convert the prices to. + * @returns The historical prices for the token. + */ + async #getHistoricalPrices_INTERNAL( + params: GetHistoricalPricesParams, + ): Promise { + assert(params, GetHistoricalPricesParamsStruct); + + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/historical-prices/{assetType}', + pathParams: { + assetType: params.assetType, + }, + encodePathParams: false, + queryParams: { + ...(params.timePeriod && { timePeriod: params.timePeriod }), + ...(params.from && { from: params.from.toString() }), + ...(params.to && { to: params.to.toString() }), + ...(params.vsCurrency && { vsCurrency: params.vsCurrency }), + }, + }); + + const response = await this.#fetch(url); + const historicalPrices = await response.json(); + assert(historicalPrices, GetHistoricalPricesResponseStruct); + + return historicalPrices; + } + + /** + * Get historical prices for a token by calling the Price API. + * It caches the results for 1 hour. + * + * @see https://price.uat-api.cx.metamask.io/docs#/Historical%20Prices/PriceController_getHistoricalPricesByCaipAssetId + * @param params - The parameters for the request. + * @param params.assetType - The asset type of the token. + * @param params.timePeriod - The time period for the historical prices. + * @param params.from - The start date for the historical prices. + * @param params.to - The end date for the historical prices. + * @param params.vsCurrency - The currency to convert the prices to. + * @returns The historical prices for the token. + */ + async getHistoricalPrices( + params: GetHistoricalPricesParams, + ): Promise { + return useCache( + this.#getHistoricalPrices_INTERNAL.bind(this), + this.#cache, + { + functionName: 'PriceApiClient:getHistoricalPrices', + ttlMilliseconds: this.cacheTtlsMilliseconds.historicalPrices, + }, + )(params); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/historical-prices.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/historical-prices.ts new file mode 100644 index 00000000..c49401ef --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/historical-prices.ts @@ -0,0 +1,17 @@ +export const MOCK_HISTORICAL_PRICES = { + prices: [ + [1740927906629, 0.4118878563926736], + [1740931479807, 0.42205009065536164], + [1740935079843, 0.45470438113431433], + ], + marketCaps: [ + [1740927906629, 1817840725.6040797], + [1740931479807, 1868369182.2913468], + [1740935079843, 2012074624.0219033], + ], + totalVolumes: [ + [1740927906629, 120486002.56343293], + [1740931479807, 147850728.76918542], + [1740935079843, 220405205.04882324], + ], +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/spot-prices.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/spot-prices.ts new file mode 100644 index 00000000..57286400 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/mocks/spot-prices.ts @@ -0,0 +1,163 @@ +import type { SpotPrices } from '../types'; + +export const MOCK_SPOT_PRICES: SpotPrices = { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + id: 'bitcoin', + price: 77556.84849999227, + marketCap: 1540421085883.0198, + allTimeHigh: 100847.44951017378, + allTimeLow: 62.86163248290115, + totalVolume: 23748436299.895576, + high1d: 78330.91520288598, + low1d: 75747.29376460482, + circulatingSupply: 19844921, + dilutedMarketCap: 1540421085883.0198, + marketCapPercentChange1d: 1.53313, + priceChange1d: 1132.76, + pricePercentChange1h: -0.4456714429821922, + pricePercentChange1d: 1.3725526422881404, + pricePercentChange7d: -4.2914380354332256, + pricePercentChange14d: 1.3530761284206316, + pricePercentChange30d: -2.6647248645353425, + pricePercentChange200d: 44.69565022141291, + pricePercentChange1y: 20.367003699380124, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 1724.8431002851428, + marketCap: 208326525244.77222, + allTimeHigh: 4522.273813243435, + allTimeLow: 0.4013827867691204, + totalVolume: 14672129201.423573, + high1d: 1750.5958823287203, + low1d: 1670.927268620115, + circulatingSupply: 120659504.7581715, + dilutedMarketCap: 208326525244.77222, + marketCapPercentChange1d: 2.19045, + priceChange1d: 36.42, + pricePercentChange1h: -0.16193070976498064, + pricePercentChange1d: 1.9964598342126199, + pricePercentChange7d: -10.123102834312476, + pricePercentChange14d: -1.7452971064771636, + pricePercentChange30d: -16.78602306244949, + pricePercentChange200d: -21.026646670919543, + pricePercentChange1y: -47.45246230239663, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + id: 'solana', + price: 117.40784182214172, + marketCap: 60217502031.67665, + allTimeHigh: 271.90599356377726, + allTimeLow: 0.46425554356391946, + totalVolume: 3389485617.517553, + high1d: 120.28162239575909, + low1d: 114.6267638476733, + circulatingSupply: 512506275.4700137, + dilutedMarketCap: 70208307228.42435, + marketCapPercentChange1d: 1.82897, + priceChange1d: 2.03, + pricePercentChange1h: -0.7015657267954617, + pricePercentChange1d: 1.6270441732346845, + pricePercentChange7d: -10.985589910714582, + pricePercentChange14d: 2.557473792001135, + pricePercentChange30d: -11.519171371325216, + pricePercentChange200d: -4.453777067234332, + pricePercentChange1y: -35.331458644625535, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'usd-coin', + price: 0.9270204293335238, + marketCap: 55688170578.59265, + allTimeHigh: 1.084620410042683, + allTimeLow: 0.8136015803527613, + totalVolume: 8880279668.334307, + high1d: 0.9270204293335238, + low1d: 0.9267951620175918, + circulatingSupply: 60073257677.05562, + dilutedMarketCap: 55717659417.21691, + marketCapPercentChange1d: 0.02765, + priceChange1d: 0.00012002, + pricePercentChange1h: 0.005951260480466856, + pricePercentChange1d: 0.012003855299833856, + pricePercentChange7d: 0.010535714950044883, + pricePercentChange14d: 0.013896106834960937, + pricePercentChange30d: 0.009428708838368462, + pricePercentChange200d: -0.03983945503023834, + pricePercentChange1y: 0.0011388468382004923, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': null, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + id: 'usd-coin', + price: 0.9270204293335238, + marketCap: 55688170578.59265, + allTimeHigh: 1.084620410042683, + allTimeLow: 0.8136015803527613, + totalVolume: 8880279668.334307, + high1d: 0.9270204293335238, + low1d: 0.9267951620175918, + circulatingSupply: 60073257677.05562, + dilutedMarketCap: 55717659417.21691, + marketCapPercentChange1d: 0.02765, + priceChange1d: 0.00012002, + pricePercentChange1h: 0.005951260480466856, + pricePercentChange1d: 0.012003855299833856, + pricePercentChange7d: 0.010535714950044883, + pricePercentChange14d: 0.013896106834960937, + pricePercentChange30d: 0.009428708838368462, + pricePercentChange200d: -0.03983945503023834, + pricePercentChange1y: 0.0011388468382004923, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr': + { + id: 'euro-coin', + price: 1.0002610448171412, + marketCap: 142095635.08509836, + allTimeHigh: 1.2514850885107882, + allTimeLow: 0.04899146959823566, + totalVolume: 25199808.258576106, + high1d: 1.0048961747745884, + low1d: 0.9993340188256516, + circulatingSupply: 142084788.4864096, + dilutedMarketCap: 142095635.08509836, + marketCapPercentChange1d: 2.2575, + priceChange1d: -0.002559725137667668, + pricePercentChange1h: 0.03324277835545184, + pricePercentChange1d: -0.23674527641785267, + pricePercentChange7d: -0.2372037121786562, + pricePercentChange14d: -1.230607529415818, + pricePercentChange30d: 4.034620460890957, + pricePercentChange200d: -2.4622235894139086, + pricePercentChange1y: 0.2685816973195049, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, +} as const; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/structs.test.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/structs.test.ts new file mode 100644 index 00000000..1d1ca018 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/structs.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { assert } from '@metamask/superstruct'; +import { cloneDeep } from 'lodash'; + +import { MOCK_SPOT_PRICES } from './mocks/spot-prices'; +import { SpotPricesStruct } from './types'; + +describe('structs', () => { + describe('SpotPricesFromPriceApiWithIncludeMarketDataFalseStruct', () => { + it('should validate the struct', () => { + const spotPrices = MOCK_SPOT_PRICES; + + expect(() => assert(spotPrices, SpotPricesStruct)).not.toThrow(); + }); + + it('should reject invalid spot prices', () => { + const spotPricesWithInvalidPrice = cloneDeep(MOCK_SPOT_PRICES); + spotPricesWithInvalidPrice[ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' + ]!.price = -4; + + expect(() => + assert(spotPricesWithInvalidPrice, SpotPricesStruct), + ).toThrow( + 'At path: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501.price -- Expected a number greater than or equal to 0 but received `-4`', + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/clients/price-api/types.ts b/merged-packages/solana-wallet-snap/src/core/clients/price-api/types.ts new file mode 100644 index 00000000..40ce9dc8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/price-api/types.ts @@ -0,0 +1,228 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + enums, + min, + nullable, + number, + object, + optional, + pattern, + record, + string, + tuple, + union, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +export type PriceApiClientConfig = { + baseUrl: string; +}; + +export const CryptoTickerStruct = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'bits', + 'sats', + 'sol', + 'sei', + 'sonic', +]); + +export const FiatTickerStruct = enums([ + 'usd', + 'aed', + 'amd', + 'ars', + 'aud', + 'bam', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'cop', + 'crc', + 'czk', + 'dkk', + 'dop', + 'eur', + 'gbp', + 'gel', + 'gtq', + 'hkd', + 'hnl', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'kes', + 'krw', + 'kwd', + 'lbp', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'pen', + 'php', + 'pkr', + 'pln', + 'ron', + 'rub', + 'sar', + 'sek', + 'sgd', + 'svc', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'xdr', + 'zar', + 'zmw', +]); + +export const CommodityTickerStruct = enums(['xag', 'xau']); + +export type CryptoTicker = Infer; +export type FiatTicker = Infer; +export type CommodityTicker = Infer; + +export const TickerStruct = union([ + CryptoTickerStruct, + FiatTickerStruct, + CommodityTickerStruct, +]); + +export type Ticker = Infer; + +export type ExchangeRate = { + name: string; + ticker: Ticker; + value: number; + currencyType: 'fiat' | 'crypto' | 'commodity'; +}; + +/** + * The structure of the spot price response from the Price API as described in + * [this file](https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/types/price.ts#L46-L71). + * + * For safety, most fields are marked optional and nullable even though it goes against the type in the Price API source code. + */ + +export const SpotPriceStruct = object({ + id: string(), + price: min(number(), 0), + marketCap: optional(nullable(min(number(), 0))), + allTimeHigh: optional(nullable(min(number(), 0))), + allTimeLow: optional(nullable(min(number(), 0))), + totalVolume: optional(nullable(min(number(), 0))), + high1d: optional(nullable(min(number(), 0))), + low1d: optional(nullable(min(number(), 0))), + circulatingSupply: optional(nullable(min(number(), 0))), + dilutedMarketCap: optional(nullable(min(number(), 0))), + marketCapPercentChange1d: optional(nullable(number())), + priceChange1d: optional(nullable(number())), + pricePercentChange1h: optional(nullable(number())), + pricePercentChange1d: optional(nullable(number())), + pricePercentChange7d: optional(nullable(number())), + pricePercentChange14d: optional(nullable(number())), + pricePercentChange30d: optional(nullable(number())), + pricePercentChange200d: optional(nullable(number())), + pricePercentChange1y: optional(nullable(number())), + bondingCurveProgressPercent: optional(nullable(number())), + liquidity: optional(nullable(number())), + totalSupply: optional(nullable(number())), + holderCount: optional(nullable(number())), + isMutable: optional(nullable(boolean())), +}); + +export type SpotPrice = Infer; + +/** + * @example + * { + * "bip122:000000000019d6689c085ae165831e93/slip44:0": { + * "id": "bitcoin", + * "price": 84302, + * "marketCap": 1670808919774, + * "allTimeHigh": 108786, + * "allTimeLow": 67.81, + * "totalVolume": 25784747348, + * "high1d": 84370, + * "low1d": 81426, + * "circulatingSupply": 19844840, + * "dilutedMarketCap": 1670808919774, + * "marketCapPercentChange1d": 3.2788, + * "priceChange1d": 2876.1, + * "pricePercentChange1h": 0.1991278666784771, + * "pricePercentChange1d": 3.5321815522315307, + * "pricePercentChange7d": -3.4056070943823666, + * "pricePercentChange14d": 1.663812725054475, + * "pricePercentChange30d": -1.8166338283570667, + * "pricePercentChange200d": 45.12491105880435, + * "pricePercentChange1y": 21.403818710804778 + * }, + * "eip155:1/slip44:60": { ... }, + * "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501": null + */ +export const SpotPricesStruct = record( + CaipAssetTypeStruct, + nullable(SpotPriceStruct), +); + +export type SpotPrices = Infer; + +// In the Price API source code, the parameters `vsCurrency` and `ticker` represent the same list of values. +// We create aliases here for clarity. +export const VsCurrencyParamStruct = TickerStruct; +export type VsCurrencyParam = Infer; + +export const GetHistoricalPricesParamsStruct = object({ + assetType: CaipAssetTypeStruct, + timePeriod: optional(pattern(string(), /^[1-9][0-9]*[dmy]$/u)), // Supports days, months, years + from: optional(min(number(), 0)), + to: optional(min(number(), 0)), + vsCurrency: optional(VsCurrencyParamStruct), +}); + +export type GetHistoricalPricesParams = Infer< + typeof GetHistoricalPricesParamsStruct +>; + +export const GetHistoricalPricesResponseStruct = object({ + prices: array(tuple([number(), number()])), + marketCaps: array(tuple([number(), number()])), + totalVolumes: array(tuple([number(), number()])), +}); + +export type GetHistoricalPricesResponse = Infer< + typeof GetHistoricalPricesResponseStruct +>; + +export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = + { + prices: [], + marketCaps: [], + totalVolumes: [], + }; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.test.ts b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.test.ts new file mode 100644 index 00000000..178805ed --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.test.ts @@ -0,0 +1,88 @@ +import { Network } from '../../constants/solana'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../services/mocks/logger'; +import { SecurityAlertsApiClient } from './SecurityAlertsApiClient'; + +describe('SecurityAlertsApiClient', () => { + const mockFetch = jest.fn(); + + let client: SecurityAlertsApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + securityAlertsApi: { + baseUrl: 'https://security-alerts-api-mock-url.com', + }, + }), + } as unknown as ConfigProvider; + + client = new SecurityAlertsApiClient( + mockConfigProvider, + mockFetch, + mockLogger, + ); + + mockFetch.mockClear(); + }); + + describe('scanTransaction', () => { + it('returns the scan result', async () => { + const mockResponse = { + result: 'some-result', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const scanResult = await client.scanTransactions({ + method: 'method', + accountAddress: 'accountAddress', + transactions: ['transaction'], + scope: Network.Mainnet, + options: ['simulation', 'validation'], + origin: 'https://metamask.io', + }); + + expect(scanResult).toStrictEqual(mockResponse); + }); + + it('throws an error if the fetch fails', async () => { + const mockError = new Error('Fetch failed'); + mockFetch.mockRejectedValueOnce(mockError); + + await expect( + client.scanTransactions({ + method: 'method', + accountAddress: 'accountAddress', + transactions: ['transaction'], + scope: Network.Mainnet, + options: ['simulation', 'validation'], + origin: 'https://metamask.io', + }), + ).rejects.toThrow(mockError); + }); + + it('throws an error if the response is not ok', async () => { + const mockError = new Error('Fetch failed'); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 404, + json: jest.fn().mockRejectedValue(new Error('HTTP error! status: 404')), + }); + + await expect( + client.scanTransactions({ + method: 'method', + accountAddress: 'accountAddress', + transactions: ['transaction'], + scope: Network.Mainnet, + options: ['simulation', 'validation'], + origin: 'https://metamask.io', + }), + ).rejects.toThrow('HTTP error! status: 404'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.ts b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.ts new file mode 100644 index 00000000..29c1034c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/SecurityAlertsApiClient.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-restricted-globals */ +import bs58 from 'bs58'; + +import { Network } from '../../constants/solana'; +import type { ConfigProvider } from '../../services/config'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import type { SecurityAlertSimulationValidationResponse } from './types'; + +const SCOPE_TO_CHAIN: Record = { + [Network.Mainnet]: 'mainnet', + [Network.Devnet]: 'devnet', + [Network.Testnet]: 'testnet', + [Network.Localnet]: 'localnet', +}; + +export class SecurityAlertsApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + constructor( + configProvider: ConfigProvider, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + const { baseUrl } = configProvider.get().securityAlertsApi; + + this.#fetch = _fetch; + this.#logger = _logger; + this.#baseUrl = baseUrl; + } + + async scanTransactions({ + method, + accountAddress, + transactions, + scope, + origin, + options, + }: { + method: string; + accountAddress: string; + transactions: string[]; + scope: Network; + origin: string; + options: string[]; + }): Promise { + const base64AccountAddress = Buffer.from( + bs58.decode(accountAddress), + ).toString('base64'); + + this.#logger.info('Scanning transaction'); + + const response = await this.#fetch(`${this.#baseUrl}/solana/message/scan`, { + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + method, + encoding: 'base64', + account_address: base64AccountAddress, + metadata: { + url: origin, + }, + chain: SCOPE_TO_CHAIN[scope], + transactions, + options, + }), + }); + + return response.json(); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/types.ts b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/types.ts new file mode 100644 index 00000000..abd6d596 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/security-alerts-api/types.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * Error details + */ +export type ApiErrorDetails = { + type: string; + message: string; +}; + +export type TransactionErrorDetails = ApiErrorDetails & { + number?: number | null; + code?: string | null; + transaction_index: number; +}; + +export type InstructionErrorDetails = ApiErrorDetails & { + transaction_index: number; + instruction_index: number; + program_account: string | null; +}; + +/** + * Assets change + */ +export type AssetChange = { + usd_price: number | null; + summary: string | null; + value: number; + raw_value: number; +}; + +export type SplTokenAsset = { + type: 'TOKEN'; + address: string; + symbol: string; + name: string; + logo?: string; + decimals: number; +}; + +export type NonFungibleAsset = { + type: 'NFT'; + address: string; + symbol: string; + name: string; + logo?: string; + decimals: number; +}; + +export type CompressedNonFungibleAsset = { + type: 'CNFT'; + address: string; + symbol: string; + name: string; + logo?: string; + decimals: number; +}; + +export type NativeAsset = { + type: 'NativeToken'; + decimals: number; + logo: string | null; +}; + +export type AssetDiff = { + asset_type: string; + asset: Asset; + in: AssetChange | null; + out: AssetChange | null; +}; + +export type AccountDetails = { + type: string; + account_address: string; + description: string | null; + was_written_to: boolean; +}; + +/** + * Account details + */ +export type AccountSummary = { + account_assets_diff: AssetDiff< + NativeAsset | SplTokenAsset | NonFungibleAsset | CompressedNonFungibleAsset + >[]; + account_delegations: any[]; + account_ownerships_diff: any[]; + total_usd_diff: { + in: number; + out: number; + total: number; + }; +}; + +export type Simulation = { + asset_diff: Record< + string, + AssetDiff< + | NativeAsset + | SplTokenAsset + | NonFungibleAsset + | CompressedNonFungibleAsset + > + >; + assets_ownership_diff: Record; // Not implemented yet + delegations: Record; // Not implemented yet + accounts_details: AccountDetails[]; + account_summary: AccountSummary; +}; + +export type SecurityAlertSimulationValidationResponse = { + encoding: 'base58'; + status: 'SUCCESS' | 'ERROR'; + error: string | null; + error_details: + | ApiErrorDetails + | TransactionErrorDetails + | InstructionErrorDetails; + result: { + simulation: Simulation; + validation: { + result_type: 'Benign' | 'Warning' | 'Malicious'; + reason: + | 'shared_state_in_bulk' + | 'unknown_profiter' + | 'unfair_trade' + | 'transfer_farming' + | 'writable_accounts_farming' + | 'native_ownership_change' + | 'spl_token_ownership_change' + | 'exposure_farming' + | 'known_attacker' + | 'invalid_signature'; + features: string[]; + extended_features: any[]; // Not implemented yet + }; + }; + request_id: string; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts new file mode 100644 index 00000000..c8e0acf6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.test.ts @@ -0,0 +1,221 @@ +import type { TokenCaipAssetType } from '../../constants/solana'; +import { KnownCaip19Id, Network } from '../../constants/solana'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../services/mocks/logger'; +import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import { TokenApiClient } from './TokenApiClient'; + +const MOCK_METADATA_RESPONSE = [ + { + decimals: 9, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + name: 'Popcat 1', + symbol: 'POPCAT', + }, + { + decimals: 9, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + name: 'Popcat 2', + symbol: 'POPCAT', + }, +]; + +describe('TokenApiClient', () => { + const mockFetch = jest.fn(); + + let client: TokenApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'https://some-mock-url.com', + chunkSize: 50, + }, + staticApi: { + baseUrl: 'https://some-mock-static-url.com', + }, + }), + } as unknown as ConfigProvider; + + client = new TokenApiClient(mockConfigProvider, mockFetch, mockLogger); + }); + + describe('constructor', () => { + it('rejects invalid baseUrl', async () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'invalid-url', + }, + }), + } as unknown as ConfigProvider; + + expect( + () => new TokenApiClient(invalidConfigProvider, mockFetch, mockLogger), + ).toThrow('Invalid URL format'); + }); + }); + + describe('getTokensMetadata', () => { + it('fetches and parses token metadata', async () => { + const tokenAddresses = [ + tokenAddressToCaip19( + Network.Mainnet, + '1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ), + tokenAddressToCaip19( + Network.Mainnet, + '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ), + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_METADATA_RESPONSE), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata).toStrictEqual({ + [`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr`]: + { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr.png', + name: 'Popcat 1', + symbol: 'POPCAT', + fungible: true, + units: [ + { + decimals: 9, + name: 'Popcat 1', + symbol: 'POPCAT', + }, + ], + }, + [`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr`]: + { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr.png', + name: 'Popcat 2', + symbol: 'POPCAT', + fungible: true, + units: [ + { + decimals: 9, + name: 'Popcat 2', + symbol: 'POPCAT', + }, + ], + }, + }); + }); + + it('handles addresses in chunks when more than the limit is provided', async () => { + const tokenAddresses = Array.from({ length: 60 }, (_, i) => + tokenAddressToCaip19(Network.Devnet, `address${i}`), + ); + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_METADATA_RESPONSE), + }); + + await client.getTokensMetadata(tokenAddresses); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('rejects caip19Ids that are invalid', async () => { + await expect( + client.getTokensMetadata(['invalid-caip19-id' as TokenCaipAssetType]), + ).rejects.toThrow( + 'At path: 0 -- Expected a value of type `CaipAssetType`, but received: `"invalid-caip19-id"`', + ); + }); + + it('rejects caip19Ids that include malicious inputs', async () => { + await expect( + client.getTokensMetadata([ + KnownCaip19Id.EurcLocalnet, + 'INVALID' as TokenCaipAssetType, + ]), + ).rejects.toThrow( + 'At path: 1 -- Expected a value of type `CaipAssetType`, but received: `"INVALID"`', + ); + }); + + it('throws an error if fetch fails', async () => { + const tokenAddresses = [ + tokenAddressToCaip19(Network.Devnet, 'address1'), + tokenAddressToCaip19(Network.Devnet, 'address2'), + ]; + + const errorMessage = 'Error fetching token metadata'; + mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + errorMessage, + ); + expect(mockLogger.error).toHaveBeenCalledWith( + new Error(errorMessage), + errorMessage, + ); + }); + + it('throws an error if the response includes an invalid assetId', async () => { + const tokenAddresses = [ + tokenAddressToCaip19(Network.Devnet, 'address1'), + tokenAddressToCaip19(Network.Devnet, 'address2'), + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([ + { + decimals: 9, + assetId: 'bad-asset-id', + name: 'Popcat 1', + symbol: 'POPCAT', + }, + ]), + }); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + 'Expected a string matching `/^solana:[a-zA-Z0-9]+\\/token:[a-zA-Z0-9]+$/` but received "bad-asset-id"', + ); + }); + + it('returns default metadata if the asset type is not supported by the Token API', async () => { + const supportedAssetType = tokenAddressToCaip19( + Network.Mainnet, + '1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ); + const unsupportedAssetType = tokenAddressToCaip19( + Network.Localnet, + 'address1', + ); + const tokenAddresses = [supportedAssetType, unsupportedAssetType]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([MOCK_METADATA_RESPONSE[0]]), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata[supportedAssetType]).toBeDefined(); + expect(metadata[unsupportedAssetType]).toStrictEqual({ + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/solana/123456789abcdef/token/address1.png', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: 9 }], + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts new file mode 100644 index 00000000..eab9a186 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/TokenApiClient.ts @@ -0,0 +1,184 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import { array, assert } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; + +import type { TokenCaipAssetType } from '../../constants/solana'; +import { Network, TokenCaipAssetTypeStruct } from '../../constants/solana'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import { UrlStruct } from '../../validation/structs'; +import type { TokenMetadataStruct } from './structs'; +import { TokenMetadataResponseStruct } from './structs'; + +const DEFAULT_DECIMALS = 9; + +const DEFAULT_TOKEN_METADATA: FungibleAssetMetadata = { + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: '', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: DEFAULT_DECIMALS }], +} as const; + +export class TokenApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + readonly #tokenIconBaseUrl: string; + + public static readonly supportedNetworks = [Network.Mainnet, Network.Devnet]; + + constructor( + configProvider: ConfigProvider, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + this.#fetch = _fetch; + this.#logger = _logger; + + const { tokenApi, staticApi } = configProvider.get(); + const { baseUrl, chunkSize } = tokenApi; + + assert(baseUrl, UrlStruct); + + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + this.#tokenIconBaseUrl = staticApi.baseUrl; + } + + async #fetchTokenMetadataBatch( + assetTypes: TokenCaipAssetType[], + ): Promise> { + assert(assetTypes, array(TokenCaipAssetTypeStruct)); + + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/assets', + queryParams: { + assetIds: assetTypes.join(','), + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + assert(data, TokenMetadataResponseStruct); + + return data; + } + + async getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + try { + assert(assetTypes, array(CaipAssetTypeStruct)); + + // The Token API only supports the networks in TokenApiClient.supportedNetworks + const supportedAssetTypes = assetTypes.filter((assetType) => { + const { chainId } = parseCaipAssetType(assetType); + return TokenApiClient.supportedNetworks.includes(chainId as Network); + }); + + if (supportedAssetTypes.length !== assetTypes.length) { + this.#logger.warn( + `[TokenApiClient] Received some asset types on networks that the Token API doesn't support. They will be ignored. Supported networks: ${TokenApiClient.supportedNetworks.join( + ', ', + )}`, + ); + } + + // Split addresses into chunks + const chunks: TokenCaipAssetType[][] = []; + for (let i = 0; i < supportedAssetTypes.length; i += this.#chunkSize) { + chunks.push(supportedAssetTypes.slice(i, i + this.#chunkSize)); + } + + // Fetch metadata for each chunk + const tokenMetadataResponses = ( + await Promise.all( + chunks.map(async (chunk) => this.#fetchTokenMetadataBatch(chunk)), + ) + ).flat(); + + // Create a Map for O(1) lookups + const tokenMetadataLookup = new Map( + tokenMetadataResponses.map((item) => [item.assetId, item]), + ); + + /** + * Iterate over each asset type, and return a default value when metadata is not found, + * to ensure the returned object has exactly the same keys as the input array. + */ + const result = new Map(); + assetTypes.forEach((assetType) => { + const tokenMetadata = tokenMetadataLookup.get(assetType); + const metadata = this.#createTokenMetadata(tokenMetadata, assetType); + result.set(assetType, metadata); + }); + + return Object.fromEntries(result); + } catch (error) { + this.#logger.error(error, 'Error fetching token metadata'); + throw error; + } + } + + #buildDefaultIconUrl(assetType: TokenCaipAssetType): string { + return buildUrl({ + baseUrl: this.#tokenIconBaseUrl, + path: '/api/v2/tokenIcons/assets/{assetType}.png', + pathParams: { + assetType: assetType.replace(/:/gu, '/'), + }, + encodePathParams: false, + }); + } + + #createTokenMetadata( + tokenMetadata: Infer | undefined, + assetType: TokenCaipAssetType, + ): FungibleAssetMetadata { + const defaultIconUrl = this.#buildDefaultIconUrl(assetType); + + if (!tokenMetadata) { + this.#logger.warn( + `No metadata for ${assetType}. Returning default values.`, + ); + return { + ...DEFAULT_TOKEN_METADATA, + iconUrl: defaultIconUrl, + }; + } + + const name = tokenMetadata.name ?? DEFAULT_TOKEN_METADATA.name; + const symbol = tokenMetadata.symbol ?? DEFAULT_TOKEN_METADATA.symbol; + const decimals = tokenMetadata.decimals ?? DEFAULT_DECIMALS; + + return { + name, + symbol, + fungible: true, + iconUrl: tokenMetadata.iconUrl ?? defaultIconUrl, + units: [ + { + name, + symbol, + decimals, + }, + ], + }; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/structs.ts b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/structs.ts new file mode 100644 index 00000000..f8175906 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/clients/token-api-client/structs.ts @@ -0,0 +1,20 @@ +import { + array, + integer, + object, + optional, + string, +} from '@metamask/superstruct'; + +import { TokenCaipAssetTypeFromStringStruct } from '../../constants/solana'; +import { UrlStruct } from '../../validation/structs'; + +export const TokenMetadataStruct = object({ + decimals: integer(), + assetId: TokenCaipAssetTypeFromStringStruct, + name: optional(string()), + symbol: optional(string()), + iconUrl: optional(UrlStruct), +}); + +export const TokenMetadataResponseStruct = array(TokenMetadataStruct); diff --git a/merged-packages/solana-wallet-snap/src/core/components/ActionHeader/ActionHeader.tsx b/merged-packages/solana-wallet-snap/src/core/components/ActionHeader/ActionHeader.tsx new file mode 100644 index 00000000..8a0c09d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/components/ActionHeader/ActionHeader.tsx @@ -0,0 +1,38 @@ +import { Box, Heading, Image, Spinner, Text } from '@metamask/snaps-sdk/jsx'; + +export type ActionHeaderProps = { + title: string; + subtitle: string; + iconSrc?: string; + isLoading?: boolean; +}; + +/** + * ActionHeader component. + * + * @param props - The props for the ActionHeader component. + * @param props.title - The title of the action header. + * @param props.subtitle - The subtitle of the action header. + * @param props.iconSrc - The icon source to display. The Snaps `Image` component supports both SVG strings and image URLs (e.g. PNG/JPEG). + * @param props.isLoading - Renders a spinner IN PLACE OF THE ICON if true. + * @returns The ActionHeader component. + */ +export const ActionHeader = ({ + title, + subtitle, + iconSrc, + isLoading, +}: ActionHeaderProps) => { + return ( + + + {isLoading ? : null} + {iconSrc && !isLoading ? ( + + ) : null} + + {title} + {subtitle ? {subtitle} : null} + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/components/Domain/Domain.tsx b/merged-packages/solana-wallet-snap/src/core/components/Domain/Domain.tsx new file mode 100644 index 00000000..32392613 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/components/Domain/Domain.tsx @@ -0,0 +1,21 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Link } from '@metamask/snaps-sdk/jsx'; + +import type { Network } from '../../constants/solana'; +import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; + +export const Domain: SnapComponent<{ + domain: string; + scope: Network; + address: string; +}> = ({ domain, scope, address }) => { + return ( + + {/* TODO: Bring it back when there is a smaller avatar option */} + {/* */} + + {domain} + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/components/Navigation/Navigation.tsx b/merged-packages/solana-wallet-snap/src/core/components/Navigation/Navigation.tsx new file mode 100644 index 00000000..fd80c11d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/components/Navigation/Navigation.tsx @@ -0,0 +1,29 @@ +import { Box, Button, Heading, Icon } from '@metamask/snaps-sdk/jsx'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +type NavigationProps = { + title: string; + backButtonName?: string; +}; + +export const Navigation: SnapComponent = ({ + title, + backButtonName, +}) => { + return ( + + {backButtonName ? ( + + ) : null} + {title} + + {null} + {null} + {null} + {null} + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/constants/solana.test.ts b/merged-packages/solana-wallet-snap/src/core/constants/solana.test.ts new file mode 100644 index 00000000..6bd32975 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/constants/solana.test.ts @@ -0,0 +1,216 @@ +import { assert, is } from '@metamask/superstruct'; + +import { + KnownCaip19Id, + TokenCaipAssetTypeFromStringStruct, + TokenCaipAssetTypeStruct, +} from './solana'; + +describe('TokenCaipAssetTypeStruct', () => { + describe('validates correct CAIP-19 token IDs', () => { + it('validates known CAIP-19 IDs from the enum', () => { + const validIds = [ + KnownCaip19Id.UsdcMainnet, + KnownCaip19Id.UsdcDevnet, + KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.EurcMainnet, + KnownCaip19Id.EurcDevnet, + KnownCaip19Id.EurcLocalnet, + KnownCaip19Id.Ai16zMainnet, + ]; + + validIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeStruct)).not.toThrow(); + expect(is(id, TokenCaipAssetTypeStruct)).toBe(true); + }); + }); + + it('validates test data from TokenApiClient tests', () => { + const testIds = [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ]; + + testIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeStruct)).not.toThrow(); + expect(is(id, TokenCaipAssetTypeStruct)).toBe(true); + }); + }); + + it('validates USDC address format', () => { + const usdcId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + expect(() => assert(usdcId, TokenCaipAssetTypeStruct)).not.toThrow(); + expect(is(usdcId, TokenCaipAssetTypeStruct)).toBe(true); + }); + }); + + describe('rejects invalid CAIP-19 token IDs', () => { + it('rejects malformed IDs', () => { + const invalidIds = [ + 'bad-asset-id', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:', // missing token address + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token', // missing colon + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/extra', // extra parts + 'eip155:1/token:0x123', // wrong chain + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', // wrong asset type + ]; + + invalidIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeStruct)).toThrow( + 'Expected a string matching', + ); + expect(is(id, TokenCaipAssetTypeStruct)).toBe(false); + }); + }); + + it('rejects IDs with special characters in token address', () => { + const invalidIds = [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5@ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // @ character + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5-ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // - character + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5_ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // _ character + ]; + + invalidIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeStruct)).toThrow( + 'Expected a string matching', + ); + expect(is(id, TokenCaipAssetTypeStruct)).toBe(false); + }); + }); + }); +}); + +describe('TokenCaipAssetTypeFromStringStruct', () => { + describe('validates and returns correct CAIP-19 token IDs', () => { + it('validates known CAIP-19 IDs from the enum', () => { + const validIds = [ + KnownCaip19Id.UsdcMainnet, + KnownCaip19Id.UsdcDevnet, + KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.EurcMainnet, + KnownCaip19Id.EurcDevnet, + KnownCaip19Id.EurcLocalnet, + KnownCaip19Id.Ai16zMainnet, + ]; + + validIds.forEach((id) => { + expect(() => + assert(id, TokenCaipAssetTypeFromStringStruct), + ).not.toThrow(); + expect(is(id, TokenCaipAssetTypeFromStringStruct)).toBe(true); + }); + }); + + it('validates test data from TokenApiClient tests', () => { + const testIds = [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + ]; + + testIds.forEach((id) => { + expect(() => + assert(id, TokenCaipAssetTypeFromStringStruct), + ).not.toThrow(); + expect(is(id, TokenCaipAssetTypeFromStringStruct)).toBe(true); + }); + }); + + it('validates USDC address format', () => { + const usdcId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + expect(() => + assert(usdcId, TokenCaipAssetTypeFromStringStruct), + ).not.toThrow(); + expect(is(usdcId, TokenCaipAssetTypeFromStringStruct)).toBe(true); + }); + }); + + describe('rejects invalid inputs', () => { + it('rejects non-string values', () => { + const invalidValues = [123, null, undefined, {}, [], true, false]; + + invalidValues.forEach((value) => { + expect(() => assert(value, TokenCaipAssetTypeFromStringStruct)).toThrow( + 'Expected a string', + ); + expect(is(value, TokenCaipAssetTypeFromStringStruct)).toBe(false); + }); + }); + + it('rejects malformed string IDs', () => { + const invalidIds = [ + 'bad-asset-id', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:', // missing token address + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token', // missing colon + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/extra', // extra parts + 'eip155:1/token:0x123', // wrong chain + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', // wrong asset type + ]; + + invalidIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeFromStringStruct)).toThrow( + 'Expected a string matching', + ); + expect(is(id, TokenCaipAssetTypeFromStringStruct)).toBe(false); + }); + }); + + it('rejects IDs with special characters in token address', () => { + const invalidIds = [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5@ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // @ character + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5-ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // - character + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5_ufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // _ character + ]; + + invalidIds.forEach((id) => { + expect(() => assert(id, TokenCaipAssetTypeFromStringStruct)).toThrow( + 'Expected a string matching', + ); + expect(is(id, TokenCaipAssetTypeFromStringStruct)).toBe(false); + }); + }); + }); +}); + +describe('Integration tests with actual API data', () => { + it('validates the exact format from TokenApiClient test data', () => { + const mockApiResponse = [ + { + decimals: 9, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + name: 'Popcat 1', + symbol: 'POPCAT', + }, + { + decimals: 9, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', + name: 'Popcat 2', + symbol: 'POPCAT', + }, + ]; + + mockApiResponse.forEach((item) => { + expect(() => + assert(item.assetId, TokenCaipAssetTypeFromStringStruct), + ).not.toThrow(); + expect(is(item.assetId, TokenCaipAssetTypeFromStringStruct)).toBe(true); + }); + }); + + it('validates real USDC addresses from the codebase', () => { + const realUsdcAddresses = [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // Mainnet + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', // Devnet + ]; + + realUsdcAddresses.forEach((address) => { + expect(() => + assert(address, TokenCaipAssetTypeFromStringStruct), + ).not.toThrow(); + expect(is(address, TokenCaipAssetTypeFromStringStruct)).toBe(true); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/constants/solana.ts b/merged-packages/solana-wallet-snap/src/core/constants/solana.ts new file mode 100644 index 00000000..6f542d64 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/constants/solana.ts @@ -0,0 +1,183 @@ +import { define, pattern, string } from '@metamask/superstruct'; + +export const SOL_SYMBOL = 'SOL'; +export const SOL_IMAGE_URL = + 'https://uat-static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png'; +export const MICRO_LAMPORTS_PER_LAMPORTS = 1_000_000n; +export const LAMPORTS_PER_SOL = 1_000_000_000; +export const DEFAULT_NETWORK_BLOCK_EXPLORER_URL = 'https://solscan.io'; +export const METAMASK_ORIGIN = 'metamask'; +export const METAMASK_ORIGIN_URL = 'https://metamask.io'; + +/** + * Solana CAIP-2 Networks + * + * @see https://namespaces.chainagnostic.org/solana/caip2 + */ +export enum Network { + Mainnet = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + Devnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + Testnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', + Localnet = 'solana:123456789abcdef', +} + +export enum KnownCaip19Id { + SolMainnet = `${Network.Mainnet}/slip44:501`, + SolDevnet = `${Network.Devnet}/slip44:501`, + SolTestnet = `${Network.Testnet}/slip44:501`, + SolLocalnet = `${Network.Localnet}/slip44:501`, + UsdcMainnet = `${Network.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + UsdcDevnet = `${Network.Devnet}/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`, + UsdcLocalnet = `${Network.Localnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + EurcMainnet = `${Network.Mainnet}/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr`, + EurcDevnet = `${Network.Devnet}/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr`, + EurcLocalnet = `${Network.Localnet}/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr`, + Ai16zMainnet = `${Network.Mainnet}/token:HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC`, +} + +export type NativeCaipAssetType = `${Network}/slip44:501`; +export type TokenCaipAssetType = `${Network}/token:${string}`; +export type NftCaipAssetType = `${Network}/nft:${string}`; + +/** + * Validates a Solana native CAIP-19 ID (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501") + */ +export const NativeCaipAssetTypeStruct = pattern( + string(), + /^solana:[a-zA-Z0-9]+\/slip44:501$/u, +); + +/** + * Validates a Solana token CAIP-19 ID (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + */ +export const TokenCaipAssetTypeStruct = pattern( + string(), + /^solana:[a-zA-Z0-9]+\/token:[a-zA-Z0-9]+$/u, +); + +/** + * Validates a Solana NFT CAIP-19 ID (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + */ +export const NftCaipAssetTypeStruct = pattern( + string(), + /^solana:[a-zA-Z0-9]+\/nft:[a-zA-Z0-9]+$/u, +); + +/** + * Custom struct that validates a string and returns it as TokenCaipAssetType + * This is useful when the API returns a generic string that needs to be validated + * and typed as TokenCaipAssetType + */ +export const TokenCaipAssetTypeFromStringStruct = define( + 'TokenCaipAssetTypeFromString', + (value) => { + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + const [error] = TokenCaipAssetTypeStruct.validate(value); + + if (error) { + return error; + } + + return true; + }, +); + +export const NETWORK_TO_EXPLORER_CLUSTER = { + [Network.Mainnet]: undefined, + [Network.Devnet]: 'devnet', + [Network.Testnet]: 'testnet', + [Network.Localnet]: 'local', +}; + +export enum SolanaCaip19Tokens { + SOL = 'slip44:501', +} + +export type TokenInfo = { + symbol: string; + caip19Id: KnownCaip19Id; + address: string; + decimals: number; +}; + +export type TransactionMetadata = { + scope: Network; + origin: string; +}; + +export const TokenMetadata = { + [KnownCaip19Id.SolMainnet]: { + symbol: 'SOL', + caip19Id: KnownCaip19Id.SolMainnet, + address: 'So11111111111111111111111111111111111111112', + decimals: 9, + }, + [KnownCaip19Id.SolDevnet]: { + symbol: 'SOL', + caip19Id: KnownCaip19Id.SolDevnet, + address: 'So11111111111111111111111111111111111111112', + decimals: 9, + }, + [KnownCaip19Id.SolTestnet]: { + symbol: 'SOL', + caip19Id: KnownCaip19Id.SolTestnet, + address: 'So11111111111111111111111111111111111111112', + decimals: 9, + }, + [KnownCaip19Id.SolLocalnet]: { + symbol: 'SOL', + caip19Id: KnownCaip19Id.SolLocalnet, + address: 'So11111111111111111111111111111111111111112', + decimals: 9, + }, + [KnownCaip19Id.UsdcMainnet]: { + symbol: 'USDC', + caip19Id: KnownCaip19Id.UsdcMainnet, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + }, + [KnownCaip19Id.UsdcDevnet]: { + symbol: 'USDC', + caip19Id: KnownCaip19Id.UsdcDevnet, + address: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + decimals: 6, + }, + [KnownCaip19Id.UsdcLocalnet]: { + symbol: 'USDC', + caip19Id: KnownCaip19Id.UsdcLocalnet, + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + }, +} as const; + +export const Networks = { + [Network.Mainnet]: { + caip2Id: Network.Mainnet, + cluster: 'mainnet', + name: 'Solana Mainnet', + nativeToken: TokenMetadata[KnownCaip19Id.SolMainnet], + }, + [Network.Devnet]: { + caip2Id: Network.Devnet, + cluster: 'devnet', + name: 'Solana Devnet', + nativeToken: TokenMetadata[KnownCaip19Id.SolDevnet], + }, + [Network.Testnet]: { + caip2Id: Network.Testnet, + cluster: 'testnet', + name: 'Solana Testnet', + nativeToken: TokenMetadata[KnownCaip19Id.SolTestnet], + }, + [Network.Localnet]: { + caip2Id: Network.Localnet, + cluster: 'local', + name: 'Solana Localnet', + nativeToken: TokenMetadata[KnownCaip19Id.SolLocalnet], + }, +} as const; + +export type Caip10Address = `${Network}:${string}`; diff --git a/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.test.ts new file mode 100644 index 00000000..f2e2bd4d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.test.ts @@ -0,0 +1,331 @@ +import type { Base58EncodedBytes, Transaction } from '@solana/kit'; + +import { MOCK_EXECUTION_SCENARIO_SEND_SOL } from '../services/signer/mocks/scenarios/sendSol'; +import { MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN } from '../services/signer/mocks/scenarios/sendSplToken'; +import { EXPECTED_SWAP_SOL_TO_OBRIC_DATA } from '../test/mocks/transactions-data/swap-sol-to-obric'; +import { EXPECTED_SWAP_SOL_TO_USDC_DATA } from '../test/mocks/transactions-data/swap-sol-to-usdc'; +import { EXPECTED_SWAP_USDC_TO_JUP_DATA } from '../test/mocks/transactions-data/swap-usdc-to-jup'; +import type { SolanaTransaction } from '../types/solana'; +import { FeeCalculatorUnsupportedInputTypeError } from './errors'; +import { FeeCalculator } from './FeeCalculator'; + +describe('FeeCalculator', () => { + describe('calculateFee', () => { + describe('when the input is a Kit Transaction', () => { + it('calculates fee a simple send SOL with no priority fee', () => { + const kitTransaction: Transaction = + MOCK_EXECUTION_SCENARIO_SEND_SOL.signedTransaction; + + const feeBreakdown = FeeCalculator.calculateFee(kitTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 300, + computeUnitPriceMicroLamportsPerComputeUnit: 1000n, + priorityFee: 0n, // Since both fields above are non zero, it should be non-zero as well, but it gets swallowed by the rounding ( = lower than 1 lamport) + totalFee: 5000n, + }); + }); + + it('calculates fee a SPL token send', () => { + const kitTransaction: Transaction = + MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN.signedTransaction; + + const feeBreakdown = FeeCalculator.calculateFee(kitTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 4794, + computeUnitPriceMicroLamportsPerComputeUnit: 1000n, + priorityFee: 4n, + totalFee: 5004n, + }); + }); + }); + + describe('when the input is a base64 encoded transaction', () => { + it('calculates fee for a send SOL', () => { + const { signedTransactionBase64Encoded } = + MOCK_EXECUTION_SCENARIO_SEND_SOL; + + const feeBreakdown = FeeCalculator.calculateFee( + signedTransactionBase64Encoded, + ); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 300, + computeUnitPriceMicroLamportsPerComputeUnit: 1000n, + priorityFee: 0n, + totalFee: 5000n, + }); + }); + + it('calculates fee for some other transaction', () => { + const base64EncodedTransaction = + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAOGL90BPMeQxbCdwSbyC2lv/FG3wE/28MLN5GTUYRikvRD9kaGxPJKAoVLt6tV3mRDIMC64Ke2ttBthAfrnxYfDdJtjNxrVLYBP6VBwAW4QcrJODCTq4A0YurnmfI8K4w2eCOqjtLeJ1tGDigOcQ8vJrW8+5B/z3Osuht6LeNXKcYNN2e6UMKyu+PAm4cbix+Ajv4ojwZExmKpP/WVAUXmKrlPhsyBl8xubS/1QIgSYyG36OJXLzlDdk+evw3cLVQ78K0R5qT8KUSk+oJRvvgVQm4b+yjGtmRmd2B8atn1ZqZG9LLuzSr6EAfhR656lu+lF3wZL99DU/P4pl1hT7Ny4wCelyAZtp+XbpgUSfBu/NxYkBd5hXf1v05A7SOMOsV0uXEKjinavFTZPsjPLDavr0sb7T6a8SBqKIPML6T3oq5jKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FUGp9UXGMd0yShWY5hpHV62i164o5tLbVxzVVshAAAAAF8Be7pvj7sWgKQq4pHYnNv3ary6SldAXJ09pCa+2ikkAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WfZGzkJtUYvjI9Pg4Deh+Wb4xlObZV4AA9qrzYUv8qaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPtD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egBVuD2k2Zaz0TbFWi/F1uqUYnLl/XS/ztlXSu2/W0YsDqGXmnuD1SAyrz2Y1fk3C8Y1Y1Fwep0ifs3I9l5PHKmBqfVFxksXFEhjMlMPUrxf1ja7gibof1E49vZigAAAACs8TbrAfwcTog9I8i1hEq1mjf2at1XxemsO1PgWdNcZFyu+IFzMmg0dnWJuhEhMR2EMUEW+6TUQ3iKu5tJiClIBwoCCwwJABhYqNqaifwADQAFAlWtBAANAAkDkNADAAAAAAAOBgABAA8QEQEBDgYAAgAdEBEBARItEQADARIPEhMSHhgeGRoDAh0fGx4AEREgHgQFHBIUFQYPBwgBABARFhcUAgkdJ+UXy5d6460qAgAAACZkAAExZAECQEIPAAAAAAAj6YIgLwAAADIAABEDAgAAAQkBGgWCEnkMjW1w1PFgdAevgQo46hGC/KyyBA4xfviukUQF7PTn8uoEB3btfg=='; + + const feeBreakdown = FeeCalculator.calculateFee( + base64EncodedTransaction, + ); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 306517, + computeUnitPriceMicroLamportsPerComputeUnit: 250000n, + priorityFee: 76629n, + totalFee: 81629n, + }); + }); + }); + + describe('when the input is a base64 encoded transaction message', () => { + it('calculates fee for a send SOL', () => { + const { transactionMessageBase64Encoded } = + MOCK_EXECUTION_SCENARIO_SEND_SOL; + + const feeBreakdown = FeeCalculator.calculateFee( + transactionMessageBase64Encoded, + ); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 300, + computeUnitPriceMicroLamportsPerComputeUnit: 1000n, + priorityFee: 0n, + totalFee: 5000n, + }); + }); + }); + + describe('when the input is a Solana Transaction', () => { + it('calculates fee for a swap SOL to USDC', () => { + const solanaTransaction: SolanaTransaction = + EXPECTED_SWAP_SOL_TO_USDC_DATA; + + const feeBreakdown = FeeCalculator.calculateFee(solanaTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 100719, + computeUnitPriceMicroLamportsPerComputeUnit: 100000n, + priorityFee: 10071n, + totalFee: 15071n, + }); + + // Can also verify values at https://solscan.io/tx/2X4vKsDS56avumw6jh6Z5wj28GNvK5UkMfY1Ta2Mtouic886EGAraa1gsCJ5rkXyHCeL9p2wHRty3QHAhjf352Dp + }); + + it('calculates fee for a swap SOL to OBRIC', () => { + const solanaTransaction: SolanaTransaction = + EXPECTED_SWAP_SOL_TO_OBRIC_DATA; + + const feeBreakdown = FeeCalculator.calculateFee(solanaTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 121290, + computeUnitPriceMicroLamportsPerComputeUnit: 100000n, + priorityFee: 12129n, + totalFee: 17129n, + }); + + // Can also verify values at https://solscan.io/tx/28rWme56aMyaP8oX18unFeZg65iyDEhjLhvMBpxyFgKcn38P37ZRsssSZoHDCCr5xUfwfpqsVSSBoShLitHQLdrr + }); + + it('calculates fee for a swap USDC to JUP', () => { + const solanaTransaction: SolanaTransaction = + EXPECTED_SWAP_USDC_TO_JUP_DATA; + + const feeBreakdown = FeeCalculator.calculateFee(solanaTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 1, + baseFee: 5000n, + computeUnitLimit: 75583, + computeUnitPriceMicroLamportsPerComputeUnit: 992287n, + priorityFee: 75000n, + totalFee: 80000n, + }); + + // Can also verify values at https://solscan.io/tx/5LuTa5k9UvgM2eJknVUD9MjfcmcTP7nvFrCedU8d7ZLXCHbrrwqhXDQYTSfncm1wTSNFPZj3Y4cRkWC8CLG6Zcvh + }); + + it('calculates fee for a transaction with secp256k1 signatures', () => { + /** + * Example taken from https://solscan.io/tx/h6CgrypQ1WQcpSCxdF1ot8ghhHwdvZ2aEDq4T5D7uYgbrTsyzh2FcmUmduXpW1EJ1VMYnMxFwVNC8DE85rz8i7e, + * stripped down to only include the relevant fields + */ + const solanaTransaction = { + transaction: { + message: { + accountKeys: [ + 'FBZHdo1DRq1FPcu8oq8iFAXNxsPiQyXqBFooDWGqRMy8', + 'KeccakSecp256k11111111111111111111111111111', + 'SBondMDrcV3K4kxZR1HNVT7osZxAHVHgYXL5Ze1oMUv', + 'DznDmDUujQFSVjw5QWSQgwwPLMPvBWDdYpcS1m5YgUuw', + 'ComputeBudget111111111111111111111111111111', + ], + instructions: [ + { + accounts: [], + data: '4GitRX1TrUudSfhNjqnrEr8moSWnpf8JFgUcETEVr9zj8N1tJEPFtwkyAX2mNSF2QUAmPB8tajRgfUWBnrjL3E9yLLX9S3iN91LpR2YtR95G8Gxhydrx7fJAdUmxnz2DDvmSNSJUFFJGi7KwwFPohJK5PjAVBgiP1GjUEiFjWP7KDjWeak7qkKT7P2wGssiMd28Zdn5awtA931LWjkQ2iNf8wK6AQfpGhQqnBgmeq1PZm5SmqdvANfdrJzN1iMPLUoUgrNde8WgVSDx9RQmhbnwLeK6zbSxGUx48AUBCfXV7EncCGz5HfbyGqKjNzE52Jecv2E2U6vacEcqauESgULLWn88wvWa9Zq4nL4KFXNivrDZT6PunaM7LSe6nkdtvdLExitw1vomz7XRxXZWzSy4HKhGKL4ZzEDgRuQy2y1iGvvXwq5j1Wa' as Base58EncodedBytes, + programIdIndex: 1, + stackHeight: null, + }, + { + accounts: [6, 9, 7, 8, 5, 3, 10, 11], + data: '2MhfeB5b7uKAndiWYjwUobgC6a3WmtumxZQ9Ao4abcn9j5DGEK' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + { + accounts: [], + data: 'G9oAjh' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [], + data: '3ZCMAp4yqfCB' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + ], + }, + signatures: [ + 'h6CgrypQ1WQcpSCxdF1ot8ghhHwdvZ2aEDq4T5D7uYgbrTsyzh2FcmUmduXpW1EJ1VMYnMxFwVNC8DE85rz8i7e', + ], + }, + } as unknown as SolanaTransaction; + + const feeBreakdown = FeeCalculator.calculateFee(solanaTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 3, + secp256r1SignaturesCount: 0, + totalSignaturesCount: 4, + baseFee: 20000n, + computeUnitLimit: 50000, + computeUnitPriceMicroLamportsPerComputeUnit: 374n, + priorityFee: 18n, + totalFee: 20018n, + }); + + /** + * If we verify the values at https://solscan.io/tx/h6CgrypQ1WQcpSCxdF1ot8ghhHwdvZ2aEDq4T5D7uYgbrTsyzh2FcmUmduXpW1EJ1VMYnMxFwVNC8DE85rz8i7e, + * we notice that Solscan doesn't properly breakdown the fees (as for Sept. 17, 2025) + * - It correctly detects that the total fee is 20018 lamports ✅ + * - But it believes that there is just 1 signature, the regular ed25519 one, while in fact there are 4 in total ❌ + * - It causes it to wrongfully consider that the base fee is 5000 lamports (while it should be 5000 * 4 = 20000 lamports) ❌ + * - And so it incorrectly attributes the remaining 20018 - 5000 = 15018 lamports to the priority fee ❌ + * - A priority fee of 15018 is incorrect. It should be 18 lamports, which can be verified by calculating it manually with the compute unit limit and price. + */ + }); + + it('calculates fee for a transaction with secp256r1 signatures', () => { + /** + * Example taken from https://solscan.io/tx/2CDp2XYu3KsfhKnCmbPKoqxfF34ootxr2L8TFf5cqCZVXbotSyQB2SRbiEu3gsBceZwq9D6hFXCBhhEAUerqELXp + * stripped down to only include the relevant fields + */ + const solanaTransaction = { + transaction: { + message: { + accountKeys: [ + '3EKX2bfqj6hKNeM4yMxEDMZB4vGB9RmQcdovgQZb7GGQ', + '1QsRZqBRy1PXUZ5mgt1n1npe7BMWgJys6nKc1zeDuN8', + '11111111111111111111111111111111', + 'Secp256r1SigVerify1111111111111111111111111', + 'Sysvar1nstructions1111111111111111111111111', + 'jitodontfrontdPSjdxeD1ZUg4tmZg7hLDB9GqRMrxq', + 'sa12qbQyuQqEaDcEqEPKmZEGTdzSMaqj87nKRYbE3QE', + ], + instructions: [ + { + accounts: [0, 0, 1, 2, 4, 6, 6, 5], + data: '4tUjktD1csGoKXLhoPTyVUhSVVRXeQ33rxJqLQNS9GA6hKfYKee3hA2X9pXt65JNuDK2YfLcx6sGV1zoDJCt8S8Rus3XEiZU9B3TQdbQXp5dUcnjg2LDKaaErxSQeDNQshjciSc6oqwL3sdwGfmxJN5hDhXVnz563HH1Uc2GLmB1um8p9M8e1qtBBHEaMG14tUzpic9aHQ1v6JfhcX3k36wKpfg1V2ZP1eZRddyD3pmYHpdKRwGB8ZE8WSisT8nm9sBBcDksDCskq7h9fEjgBWjPMz82bZoPT7mkfPbhFJ25gNsjBj2AU7voN653iGAjhzkdxYNB9Yg4PULuZ8ffzAxc4hSKzm5Du5g63', + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [], + data: '37bVnf5k5XNTVwdBpMaj56HaSdM5wjEsUcWkSNab2fVyUdekdyaxYJcFq2GatwnuQyTUMMB3GWTF7gmVv9CiKbw3HGUX1868ghSuq7FdpKb2v7UHPBhEfpANQ4dithun9HJCsfmnZpcD16xTt8FL8ZvVugd76arLFRqEiUfXEqVt1M96QSiw5gAei2L8e43bLW1USz98ABchXNPFS4fqUxuoHq37Kp7RHnQgsB6CFbYVYKPqFaksnV5j', + programIdIndex: 3, + stackHeight: null, + }, + ], + }, + signatures: [ + '2CDp2XYu3KsfhKnCmbPKoqxfF34ootxr2L8TFf5cqCZVXbotSyQB2SRbiEu3gsBceZwq9D6hFXCBhhEAUerqELXp', + ], + }, + } as unknown as SolanaTransaction; + + const feeBreakdown = FeeCalculator.calculateFee(solanaTransaction); + + expect(feeBreakdown).toStrictEqual({ + ed25519SignaturesCount: 1, + secp256k1SignaturesCount: 0, + secp256r1SignaturesCount: 1, + totalSignaturesCount: 2, + baseFee: 10000n, + computeUnitLimit: 0, + computeUnitPriceMicroLamportsPerComputeUnit: 0n, + priorityFee: 0n, + totalFee: 10000n, + }); + + /** + * Here again, we notice that Solscan doesn't properly breakdown the fees (as for Sept. 17, 2025). + * - It correctly detects that the total fee is 10000 lamports ✅ + * - But it believes that there is just 1 signature, the regular ed25519 one, while in fact there are 2 in total ❌ + * - It causes it to wrongfully consider that the base fee is 5000 lamports (while it should be 5000 * 2 = 10000 lamports) ❌ + * - And so it incorrectly attributes the remaining 10000 - 5000 = 5000 lamports to the priority fee ❌ + * - A priority fee of 5000 is incorrect. It should be 0 lamports, because there is no compute unit limit or price in this transaction. + */ + }); + }); + + describe('when the input is an invalid type', () => { + it('throws error for unsupported input type', () => { + const invalidInput = { invalid: 'data' } as any; + + expect(() => FeeCalculator.calculateFee(invalidInput)).toThrow( + FeeCalculatorUnsupportedInputTypeError, + ); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.ts b/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.ts new file mode 100644 index 00000000..e390c1a2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/FeeCalculator.ts @@ -0,0 +1,380 @@ +import { + parseSetComputeUnitLimitInstruction, + parseSetComputeUnitPriceInstruction, +} from '@solana-program/compute-budget'; +import type { + CompiledTransactionMessage, + IInstructionWithData, + Transaction as KitTransaction, + Lamports, +} from '@solana/kit'; +import { lamports, pipe } from '@solana/kit'; +import BigNumber from 'bignumber.js'; + +import { parseInstruction } from '../../entities'; +import type { ParsedSecp256Instruction } from '../../entities/instructions/programs/secp256k1-secp256r1'; +import { + isSecp256k1Instruction, + isSecp256r1Instruction, +} from '../../entities/instructions/programs/secp256k1-secp256r1'; +import { MICRO_LAMPORTS_PER_LAMPORTS } from '../constants/solana'; +import { + isComputeUnitLimitInstruction, + isComputeUnitPriceInstruction, +} from '../sdk-extensions/transaction-messages'; +import type { SolanaTransaction } from '../types/solana'; +import { FeeCalculatorUnsupportedInputTypeError } from './errors'; +import { normalizeBase64EncodedTransaction } from './normalizers/normalizeBase64EncodedTransaction'; +import { normalizeBase64EncodedTransactionMessage } from './normalizers/normalizeBase64EncodedTransactionMessage'; +import { normalizeCompiledTransactionMessage } from './normalizers/normalizeCompiledTransactionMessage'; +import { normalizeKitTransaction } from './normalizers/normalizeKitTransaction'; +import { normalizeSolanaTransaction } from './normalizers/normalizeSolanaTransaction'; +import type { NormalizableInput, NormalizedInput } from './normalizers/types'; + +/** + * Base fee per signature in lamports + */ +const BASE_FEE_PER_SIGNATURE = 5000n; + +type FeeBreakdown = { + ed25519SignaturesCount: number; + secp256k1SignaturesCount: number; + secp256r1SignaturesCount: number; + /** Is equal to ed25519SignaturesCount + secp256k1SignaturesCount + secp256r1SignaturesCount */ + totalSignaturesCount: number; + /** Is equal to 5000 * totalSignaturesCount */ + baseFee: Lamports; + computeUnitLimit: number; + computeUnitPriceMicroLamportsPerComputeUnit: bigint; + /** Is equal to computeUnitLimit * computeUnitPriceMicroLamportsPerComputeUnit, modulo units */ + priorityFee: Lamports; + /** Is equal to baseFee + priorityFee */ + totalFee: Lamports; +}; + +/** + * Utility class to calculate the fees for a Solana transaction. + * + * This class provides offline fee calculation capabilities for Solana transactions, + * supporting multiple input formats and various signature types. It calculates both + * base fees (based on signature count) and priority fees (based on compute unit + * limits and prices). + * + * **Key Features:** + * - Offline-first design - no network access required + * - Supports multiple input formats (SolanaTransaction, Kit Transaction, base64 strings) + * - Handles different signature types (ed25519, secp256k1, secp256r1) + * - Accurate fee calculation following Solana's fee structure + * + * **Supported Input Types:** + * - `SolanaTransaction` - Raw transaction data from RPC responses + * - `Transaction` (aliased `KitTransaction` to avoid confusion) - `@solana/kit` transaction objects + * - `string` - Base64 encoded transaction data + * + * @example + * ```typescript + * // Calculate fees for a SolanaTransaction + * const solanaTx: SolanaTransaction = { ... }; + * const feeBreakdown = FeeCalculator.calculateFee(solanaTx); + * console.log(`Total fee: ${feeBreakdown.totalFee} lamports`); + * ``` + * @example + * ```typescript + * // Calculate fees for a Kit Transaction + * const kitTx: Transaction = { ... }; + * const feeBreakdown = FeeCalculator.calculateFee(kitTx); + * console.log(`Base fee: ${feeBreakdown.baseFee}, Priority fee: ${feeBreakdown.priorityFee}`); + * ``` + * @example + * ```typescript + * // Calculate fees for a base64 encoded transaction + * const base64Tx = "AWNHdgBrkCqvrksueTwor+5taOgd/fkS8TUavpK5+MhER3+b3fx0AdM+CvpLQ8f3x+9NBzwts66BC/bfqPkAzAKAAQACBJmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N903bOu6UdCGJS9VyhRo8wvswWSAO709XY+51AU1MALO6wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAdazMubIOjxUiTY/+xWYNSffhgTb7vd9LWQ0sI2iMMSoDAwAFAiwBAAADAAkD6AMAAAAAAAACAgABDAIAAABAQg8AAAAAAAA="; + * const feeBreakdown = FeeCalculator.calculateFee(base64Tx); + * console.log(`Signatures: ${feeBreakdown.totalSignaturesCount}`); + * ``` + */ +export class FeeCalculator { + /** + * Calculates the complete fee breakdown for a Solana transaction. + * + * This method analyzes the transaction to determine all fee components: + * - **Base Fee**: 5000 lamports per signature (ed25519, secp256k1, secp256r1). + * - **Priority Fee**: Calculated from compute unit limit × compute unit price. + * - **Total Fee**: Sum of base fee and priority fee. + * + * The method automatically detects and counts different signature types: + * - Standard ed25519 signatures from the transaction's signature array. + * - secp256k1 signatures from instruction data. + * - secp256r1 signatures from instruction data. + * + * @param input - The transaction to analyze. Can be a `SolanaTransaction`, a `@solana/kit` `Transaction`, or base64 string. + * @returns A detailed fee breakdown object containing signature counts, base fee, priority fee, and total fee. + * @example + * ```typescript + * const transaction = { ... }; + * const fees = FeeCalculator.calculateFee(transaction); + * + * console.log(`Total signatures: ${fees.totalSignaturesCount}`); + * console.log(`Base fee: ${fees.baseFee} lamports`); + * console.log(`Priority fee: ${fees.priorityFee} lamports`); + * console.log(`Total fee: ${fees.totalFee} lamports`); + * ``` + * @example + * ```typescript + * // Handle different signature types + * const fees = FeeCalculator.calculateFee(transaction); + * if (fees.secp256k1SignaturesCount > 0) { + * console.log(`Transaction includes ${fees.secp256k1SignaturesCount} secp256k1 signatures`); + * } + * ``` + */ + static calculateFee(input: NormalizableInput): FeeBreakdown { + const normalizedInput = this.#normalizeInput(input); + + const { + ed25519SignaturesCount, + secp256k1SignaturesCount, + secp256r1SignaturesCount, + totalSignaturesCount, + } = this.#countSignatures(normalizedInput); + + const baseFee = this.#calculateBaseFee(totalSignaturesCount); + + const computeUnitLimit = this.#getComputeUnitLimit(normalizedInput); + const computeUnitPriceMicroLamportsPerComputeUnit = + this.#getComputeUnitPriceMicroLamportsPerComputeUnit(normalizedInput); + + const priorityFee = this.#calculatePriorityFee( + computeUnitLimit, + computeUnitPriceMicroLamportsPerComputeUnit, + ); + + const totalFee = lamports(baseFee + priorityFee); + + return { + ed25519SignaturesCount, + secp256k1SignaturesCount, + secp256r1SignaturesCount, + totalSignaturesCount, + baseFee, + computeUnitLimit, + computeUnitPriceMicroLamportsPerComputeUnit, + priorityFee, + totalFee, + }; + } + + /** + * Calculates the base fee from the total signatures count. + * + * @param totalSignaturesCount - Total signatures count. + * @returns Base fee in lamports. + */ + static #calculateBaseFee(totalSignaturesCount: number): Lamports { + return lamports(BASE_FEE_PER_SIGNATURE * BigInt(totalSignaturesCount)); + } + + static #getComputeUnitLimit(normalizedInput: NormalizedInput): number { + const { instructions } = normalizedInput; + const computeUnitLimitInstruction = instructions.find( + isComputeUnitLimitInstruction, + ); + + if (!computeUnitLimitInstruction) { + return 0; + } + + return parseSetComputeUnitLimitInstruction( + computeUnitLimitInstruction as IInstructionWithData, + ).data.units; + } + + static #getComputeUnitPriceMicroLamportsPerComputeUnit( + normalizedInput: NormalizedInput, + ): bigint { + const { instructions } = normalizedInput; + const computeUnitPriceInstruction = instructions.find( + isComputeUnitPriceInstruction, + ); + + if (!computeUnitPriceInstruction) { + return 0n; + } + + return parseSetComputeUnitPriceInstruction( + computeUnitPriceInstruction as IInstructionWithData, + ).data.microLamports; + } + + /** + * Calculates the priority fee for a transaction. + * + * @param computeUnitLimit - Compute unit limit. + * @param getComputeUnitPriceMicroLamportsPerComputeUnit - Compute unit price. + * @returns Priority fee in lamports. + */ + static #calculatePriorityFee( + computeUnitLimit: number, + getComputeUnitPriceMicroLamportsPerComputeUnit: bigint, + ): Lamports { + return pipe( + computeUnitLimit, + (value) => BigNumber(value), + (value) => + value.multipliedBy( + getComputeUnitPriceMicroLamportsPerComputeUnit.toString(), + ), + (value) => + value.dividedToIntegerBy(MICRO_LAMPORTS_PER_LAMPORTS.toString()), + (value) => value.toString(), + BigInt, + lamports, + ); + } + + /** + * Counts all signature types in the transaction. + * + * @param normalizedInput - Normalized input. + * @returns Object with counts of each signature type. + */ + static #countSignatures(normalizedInput: NormalizedInput): { + ed25519SignaturesCount: number; + secp256k1SignaturesCount: number; + secp256r1SignaturesCount: number; + totalSignaturesCount: number; + } { + const { ed25519Signatures, instructions } = normalizedInput; + + // Count ed25519 signatures (standard transaction signatures) + const ed25519Count = ed25519Signatures.length; + + // Count secp256k1 signatures from instructions + const secp256k1Count = instructions + .filter(isSecp256k1Instruction) + .map(parseInstruction) + .reduce( + (acc, instruction) => + acc + + ((instruction.parsed as ParsedSecp256Instruction)?.data + .numSignatures ?? 0), + 0, + ); + + // Count secp256r1 signatures from instructions + const secp256r1Count = instructions + .filter(isSecp256r1Instruction) + .map(parseInstruction) + .reduce( + (acc, instruction) => + acc + + ((instruction.parsed as ParsedSecp256Instruction)?.data + .numSignatures ?? 0), + 0, + ); + + return { + ed25519SignaturesCount: ed25519Count, + secp256k1SignaturesCount: secp256k1Count, + secp256r1SignaturesCount: secp256r1Count, + totalSignaturesCount: ed25519Count + secp256k1Count + secp256r1Count, + }; + } + + /** + * Normalizes the various input types to a common format. + * + * @param input - Transaction message or transaction data. + * @returns Normalized input. + */ + static #normalizeInput(input: NormalizableInput): NormalizedInput { + if (this.#isBase64EncodedTransaction(input)) { + return normalizeBase64EncodedTransaction(input); + } + + if (this.#isBase64EncodedTransactionMessage(input)) { + return normalizeBase64EncodedTransactionMessage(input); + } + + if (this.#isCompiledTransactionMessage(input)) { + return normalizeCompiledTransactionMessage(input); + } + + if (this.#isKitTransaction(input)) { + return normalizeKitTransaction(input); + } + + if (this.#isSolanaTransaction(input)) { + return normalizeSolanaTransaction(input); + } + + throw new FeeCalculatorUnsupportedInputTypeError( + 'The fee calculator could not recognize the input type. It supports SolanaTransactions, kit Transactions, and base64 strings.', + ); + } + + static #isBase64EncodedTransaction( + input: NormalizableInput, + ): input is string { + try { + if (typeof input !== 'string') { + return false; + } + normalizeBase64EncodedTransaction(input); + return true; + } catch (error) { + return false; + } + } + + static #isBase64EncodedTransactionMessage( + input: NormalizableInput, + ): input is string { + try { + if (typeof input !== 'string') { + return false; + } + normalizeBase64EncodedTransactionMessage(input); + return true; + } catch (error) { + return false; + } + } + + static #isCompiledTransactionMessage( + input: NormalizableInput, + ): input is CompiledTransactionMessage { + return ( + typeof input === 'object' && + 'instructions' in input && + 'staticAccounts' in input + ); + } + + static #isSolanaTransaction( + input: NormalizableInput, + ): input is SolanaTransaction { + return ( + typeof input === 'object' && + 'transaction' in input && + typeof input.transaction === 'object' && + 'message' in input.transaction && + typeof input.transaction.message === 'object' && + 'accountKeys' in input.transaction.message && + typeof input.transaction.message.accountKeys === 'object' && + 'instructions' in input.transaction.message && + typeof input.transaction.message.instructions === 'object' && + 'signatures' in input.transaction && + typeof input.transaction.signatures === 'object' + ); + } + + static #isKitTransaction(input: NormalizableInput): input is KitTransaction { + return ( + typeof input === 'object' && + 'messageBytes' in input && + typeof input.messageBytes === 'object' && + 'signatures' in input + ); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/fees/errors.ts b/merged-packages/solana-wallet-snap/src/core/fees/errors.ts new file mode 100644 index 00000000..090dc0a9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/errors.ts @@ -0,0 +1,13 @@ +export abstract class FeeCalculatorError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeeCalculatorError'; + } +} + +export class FeeCalculatorUnsupportedInputTypeError extends FeeCalculatorError { + constructor(message: string) { + super(message); + this.name = 'FeeCalculatorUnsupportedInputTypeError'; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/fees/index.ts b/merged-packages/solana-wallet-snap/src/core/fees/index.ts new file mode 100644 index 00000000..69f6914a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/index.ts @@ -0,0 +1 @@ +export * from './FeeCalculator'; diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.test.ts new file mode 100644 index 00000000..fc373390 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.test.ts @@ -0,0 +1,56 @@ +import { MOCK_EXECUTION_SCENARIO_LIFI_SWAP } from '../../services/signer/mocks/scenarios/lifiSwap'; +import { normalizeBase64EncodedTransaction } from './normalizeBase64EncodedTransaction'; + +describe('normalizeBase64EncodedTransaction', () => { + it('normalizes a Lifi Swap transaction correctly', () => { + const { signedTransactionBase64Encoded } = + MOCK_EXECUTION_SCENARIO_LIFI_SWAP; + + const result = normalizeBase64EncodedTransaction( + signedTransactionBase64Encoded, + ); + + expect(result).toStrictEqual({ + ed25519Signatures: [ + new Uint8Array([ + 11, 34, 157, 107, 188, 168, 51, 10, 75, 131, 104, 67, 181, 199, 19, + 239, 51, 223, 112, 156, 236, 164, 212, 5, 131, 172, 248, 115, 35, 244, + 132, 219, 249, 196, 254, 153, 3, 104, 152, 72, 2, 208, 23, 254, 129, + 247, 107, 253, 49, 78, 204, 25, 25, 158, 145, 243, 244, 146, 176, 229, + 187, 120, 7, 0, + ]), + ], + instructions: [ + { + accounts: [], + data: new Uint8Array([0, 139, 203, 23, 160, 77, 48, 130, 0]), + programAddress: '3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br', + }, + { + accounts: [], + data: new Uint8Array([2, 184, 129, 22, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([3, 32, 161, 7, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([1]), + programAddress: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + }, + { + accounts: [], + data: new Uint8Array([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, + 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, + 72, 81, 1, 0, 0, 0, 50, 0, 0, + ]), + programAddress: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + }, + ], + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.ts new file mode 100644 index 00000000..b2a233dc --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransaction.ts @@ -0,0 +1,14 @@ +import { getBase64Encoder, getTransactionDecoder, pipe } from '@solana/kit'; + +import { normalizeKitTransaction } from './normalizeKitTransaction'; +import type { NormalizedInput } from './types'; + +export const normalizeBase64EncodedTransaction = ( + base64String: string, +): NormalizedInput => + pipe( + base64String, + getBase64Encoder().encode, + getTransactionDecoder().decode, // This gives us a kit Transaction + normalizeKitTransaction, // So we can re-use the normalizeKitTransaction function + ); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.test.ts new file mode 100644 index 00000000..e8fa711d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.test.ts @@ -0,0 +1,45 @@ +import { normalizeBase64EncodedTransactionMessage } from './normalizeBase64EncodedTransactionMessage'; + +describe('normalizeBase64EncodedTransactionMessage', () => { + it('normalizes a base64 encoded transaction message correctly', () => { + const base64String = + 'gAEAChfds67pR0IYlL1XKFGjzC+zBZIA7vT1dj7nUBTUwAs7rBn3hrWmXImk+UetGwvWumZpcEhF+xT5THD5os2Svp67H8GdfJ4jkOslaYOff/X0SeF33Cha5Ij9DKlo3QaosfIhtgnDmdJAVdjfmyv5dEGsSWgYEYPaqW8v8hSJozhYIzcQa4p5MinFjNMq4vDm+n249hE5Vmwe+Adkq87GTDegPbdaVhuqlrT5hZnIkdcW67eCFvm9ZzXM9jeWm2GwnLBGfnLp6qHLD5IgtqLXr5clzwoa2ns4KdysosGA2yFHt2dBBA/kB6qwUEagfnGzH2GY12XE3va/gn3W4Loqy/D+gP5PMjWwL4nGY8i3EGxqLHt/i3x/EskcO1ftQjYqQtqMVVDE+U/Vngb6x6+HbBOGrDQOx73j0k813TrK9dmptLgHDBoOIz6tGmWT+r9wa3YtqouLIv01/IKynGlc4TnE0M2MheikqA6gkuj1PhXVDgPc7eSLCseVMCy/WUgTmKbXmnZ/QcH3X8YTJ//GR4yvL7LCHdfHPhS8B+4hpoFusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FVRPixdukLDvYMw2r2DTumX5VA1ifoAVfXgkOTnLswDEoyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6LwHxW5grT0/F3OC6sZUj7of0yz9kMoCs+fPoYX9znOYyUOdJ8PoWqEFw7H8n7xwkhg1P1tPfo1/6arncTl4PJkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwan1RcYx3TJKFZjmGkdXraLXrijm0ttXHNVWyEAAAAA0LlMW0o1bBeKH7IjSDuI9G0a+7kgVaW9ubbU+rklS9sFDgIWFAkAi8sXoE0wggARAAUCuIEWABEACAMgoQcAAAAAEAYACAATDS0BARVDLQ8ABgUECCoTFRUSFSslKyIjBQooKiQrDy0tKSsMAwEVKyErHB0KBygsHisPLS0pKx8gFScPJhsHBBcZGi0JGAILFSzBIJszQdacgQMDAAAAJmQAASZkAQIaZAIDQEIPAAAAAAC78khRAQAAADIAAANOgLKSmCyUmuksqMclFoVdtmiFizz7/yF11zNd6VSAxgUkIB4dIwIhIrYRAfelfqMdEh4JHXx6VS3GXpyeWhNKQlBsx9m2I8c+BhMSEBEUWgDdfctSzc+t7n0tohMIoz7S6USQkKhKDRCUSx6C3SjhJQQJAg8EBgoMCwYQBw=='; + + const result = normalizeBase64EncodedTransactionMessage(base64String); + + expect(result).toStrictEqual({ + ed25519Signatures: ['signature'], + instructions: [ + { + accounts: [], + data: new Uint8Array([0, 139, 203, 23, 160, 77, 48, 130, 0]), + programAddress: '3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br', + }, + { + accounts: [], + data: new Uint8Array([2, 184, 129, 22, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([3, 32, 161, 7, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([1]), + programAddress: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + }, + { + accounts: [], + data: new Uint8Array([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, + 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, + 72, 81, 1, 0, 0, 0, 50, 0, 0, + ]), + programAddress: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + }, + ], + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.ts new file mode 100644 index 00000000..987eba74 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeBase64EncodedTransactionMessage.ts @@ -0,0 +1,18 @@ +import { + getBase64Encoder, + getCompiledTransactionMessageDecoder, + pipe, +} from '@solana/kit'; + +import { normalizeCompiledTransactionMessage } from './normalizeCompiledTransactionMessage'; +import type { NormalizedInput } from './types'; + +export const normalizeBase64EncodedTransactionMessage = ( + base64String: string, +): NormalizedInput => + pipe( + base64String, + getBase64Encoder().encode, + getCompiledTransactionMessageDecoder().decode, + normalizeCompiledTransactionMessage, + ); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.test.ts new file mode 100644 index 00000000..b94d2316 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.test.ts @@ -0,0 +1,82 @@ +import { address } from '@solana/kit'; +import type { CompiledTransactionMessage } from '@solana/kit'; + +import { normalizeCompiledTransactionMessage } from './normalizeCompiledTransactionMessage'; + +describe('normalizeCompiledTransactionMessage', () => { + it('normalizes a compiled transaction message correctly', () => { + const mockCompiledTransactionMessage: CompiledTransactionMessage = { + staticAccounts: [ + address('3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br'), + address('ComputeBudget111111111111111111111111111111'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + ], + instructions: [ + { + programAddressIndex: 0, + data: new Uint8Array([0, 139, 203, 23, 160, 77, 48, 130, 0]), + }, + { + programAddressIndex: 1, + data: new Uint8Array([2, 184, 129, 22, 0]), + }, + { + programAddressIndex: 1, + data: new Uint8Array([3, 32, 161, 7, 0, 0, 0, 0]), + }, + { + programAddressIndex: 2, + data: new Uint8Array([1]), + }, + { + programAddressIndex: 3, + data: new Uint8Array([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, + 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, + 72, 81, 1, 0, 0, 0, 50, 0, 0, + ]), + }, + ], + } as unknown as CompiledTransactionMessage; + + const result = normalizeCompiledTransactionMessage( + mockCompiledTransactionMessage, + ); + + expect(result).toStrictEqual({ + ed25519Signatures: ['signature'], + instructions: [ + { + accounts: [], + data: new Uint8Array([0, 139, 203, 23, 160, 77, 48, 130, 0]), + programAddress: '3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br', + }, + { + accounts: [], + data: new Uint8Array([2, 184, 129, 22, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([3, 32, 161, 7, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([1]), + programAddress: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + }, + { + accounts: [], + data: new Uint8Array([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, + 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, + 72, 81, 1, 0, 0, 0, 50, 0, 0, + ]), + programAddress: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + }, + ], + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.ts new file mode 100644 index 00000000..d2be0109 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeCompiledTransactionMessage.ts @@ -0,0 +1,16 @@ +import type { CompiledTransactionMessage } from '@solana/kit'; + +import type { NormalizedInput } from './types'; + +export const normalizeCompiledTransactionMessage = ( + compiledTransactionMessage: CompiledTransactionMessage, +): NormalizedInput => ({ + ed25519Signatures: ['signature'], // The compiled transaction message doesn't have ed25519 signatures yet. Best guess is that there will be exactly one, so we fake it. + instructions: compiledTransactionMessage.instructions.map((item) => ({ + accounts: [], // We don't need them + data: item.data ?? new Uint8Array(), + programAddress: + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + compiledTransactionMessage.staticAccounts[item.programAddressIndex]!, + })), +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.test.ts new file mode 100644 index 00000000..75c7334c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.test.ts @@ -0,0 +1,53 @@ +import { MOCK_EXECUTION_SCENARIO_LIFI_SWAP } from '../../services/signer/mocks/scenarios/lifiSwap'; +import { normalizeKitTransaction } from './normalizeKitTransaction'; + +describe('normalizeKitTransaction', () => { + it('normalizes a Lifi Swap transaction correctly', () => { + const mockTransaction = MOCK_EXECUTION_SCENARIO_LIFI_SWAP.signedTransaction; + + const result = normalizeKitTransaction(mockTransaction); + + expect(result).toStrictEqual({ + ed25519Signatures: [ + new Uint8Array([ + 11, 34, 157, 107, 188, 168, 51, 10, 75, 131, 104, 67, 181, 199, 19, + 239, 51, 223, 112, 156, 236, 164, 212, 5, 131, 172, 248, 115, 35, 244, + 132, 219, 249, 196, 254, 153, 3, 104, 152, 72, 2, 208, 23, 254, 129, + 247, 107, 253, 49, 78, 204, 25, 25, 158, 145, 243, 244, 146, 176, 229, + 187, 120, 7, 0, + ]), + ], + instructions: [ + { + accounts: [], + data: new Uint8Array([0, 139, 203, 23, 160, 77, 48, 130, 0]), + programAddress: '3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br', + }, + { + accounts: [], + data: new Uint8Array([2, 184, 129, 22, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([3, 32, 161, 7, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + accounts: [], + data: new Uint8Array([1]), + programAddress: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + }, + { + accounts: [], + data: new Uint8Array([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, + 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, + 72, 81, 1, 0, 0, 0, 50, 0, 0, + ]), + programAddress: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + }, + ], + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.ts new file mode 100644 index 00000000..2af0a635 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeKitTransaction.ts @@ -0,0 +1,21 @@ +import { getCompiledTransactionMessageDecoder, pipe } from '@solana/kit'; +import type { Transaction as KitTransaction } from '@solana/kit'; + +import { normalizeCompiledTransactionMessage } from './normalizeCompiledTransactionMessage'; +import type { NormalizedInput } from './types'; + +export const normalizeKitTransaction = ( + input: KitTransaction, +): NormalizedInput => { + const normalizedTransactionMessage = pipe( + input.messageBytes, + getCompiledTransactionMessageDecoder().decode, + normalizeCompiledTransactionMessage, + ); + + return { + ...normalizedTransactionMessage, + // Unlike the compiled transaction message, the transaction actually has ed25519 signatures + ed25519Signatures: Object.values(input.signatures), + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.test.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.test.ts new file mode 100644 index 00000000..14e0f1dc --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.test.ts @@ -0,0 +1,52 @@ +import { EXPECTED_SEND_SPL_TOKEN_AND_CREATE_TOKEN_ACCOUNT_DATA } from '../../test/mocks/transactions-data/send-spl-token-and-create-token-account'; +import { EXPECTED_SWAP_SOL_TO_USDC_DATA } from '../../test/mocks/transactions-data/swap-sol-to-usdc'; +import { normalizeSolanaTransaction } from './normalizeSolanaTransaction'; + +describe('normalizeSolanaTransaction', () => { + it('normalizes an SPL token transfer with account creation', () => { + const result = normalizeSolanaTransaction( + EXPECTED_SEND_SPL_TOKEN_AND_CREATE_TOKEN_ACCOUNT_DATA, + ); + + expect(result.ed25519Signatures).toHaveLength(1); + expect(result.instructions.length).toBeGreaterThan(1); + + const programAddresses = result.instructions.map((i) => i.programAddress); + expect(programAddresses).toContain( + 'ComputeBudget111111111111111111111111111111', + ); + expect(programAddresses).toContain( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + ); + expect(programAddresses).toContain( + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + ); + + result.instructions.forEach((instruction) => { + expect(Array.isArray(instruction.accounts)).toBe(true); + expect(instruction.data).toBeInstanceOf(Uint8Array); + expect(typeof instruction.programAddress).toBe('string'); + }); + }); + + it('normalizes a Jupiter swap transaction', () => { + const result = normalizeSolanaTransaction(EXPECTED_SWAP_SOL_TO_USDC_DATA); + + expect(result.ed25519Signatures).toHaveLength(1); + expect(result.instructions.length).toBeGreaterThan(3); + + const programAddresses = result.instructions.map((i) => i.programAddress); + expect(programAddresses).toContain( + 'ComputeBudget111111111111111111111111111111', + ); + expect(programAddresses).toContain( + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + ); + + result.instructions.forEach((instruction) => { + expect(Array.isArray(instruction.accounts)).toBe(true); + expect(instruction.data).toBeInstanceOf(Uint8Array); + expect(typeof instruction.programAddress).toBe('string'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.ts new file mode 100644 index 00000000..99b7b70b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/normalizeSolanaTransaction.ts @@ -0,0 +1,14 @@ +import { toIInstruction } from '../../../entities'; +import type { SolanaTransaction } from '../../types/solana'; +import type { NormalizedInput } from './types'; + +export const normalizeSolanaTransaction = ( + input: SolanaTransaction, +): NormalizedInput => { + return { + ed25519Signatures: input.transaction.signatures, + instructions: input.transaction.message.instructions.map((instruction) => + toIInstruction(instruction, input), + ), + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/fees/normalizers/types.ts b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/types.ts new file mode 100644 index 00000000..d581c07c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/fees/normalizers/types.ts @@ -0,0 +1,18 @@ +import type { + CompiledTransactionMessage, + IInstruction, + Transaction as KitTransaction, +} from '@solana/kit'; + +import type { SolanaTransaction } from '../../types/solana'; + +export type NormalizableInput = + | SolanaTransaction + | KitTransaction + | CompiledTransactionMessage + | string; + +export type NormalizedInput = { + ed25519Signatures: readonly any[]; + instructions: readonly IInstruction[]; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/index.ts new file mode 100644 index 00000000..fe5130d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/index.ts @@ -0,0 +1 @@ +export * from './onClientRequest'; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onAssetHistoricalPrice/onAssetHistoricalPrice.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetHistoricalPrice/onAssetHistoricalPrice.ts new file mode 100644 index 00000000..0bf2461a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetHistoricalPrice/onAssetHistoricalPrice.ts @@ -0,0 +1,29 @@ +import type { OnAssetHistoricalPriceHandler } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +import { tokenPricesService } from '../../../snapContext'; +import logger from '../../utils/logger'; + +/** + * Implements the `onAssetHistoricalPrice` handler. + * + * @see https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-29.md#get-assets-historical-price + * @param params - The parameters for the `onAssetHistoricalPrice` handler. + * @returns The historical price of the asset pair. + */ +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( + params, +) => { + logger.log('[📈 onAssetHistoricalPrice]', params); + + const { from, to } = params; + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const historicalPrice = await tokenPricesService.getHistoricalPrice(from, to); + + return { + historicalPrice, + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsConversion/onAssetsConversion.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsConversion/onAssetsConversion.ts new file mode 100644 index 00000000..fef0f375 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsConversion/onAssetsConversion.ts @@ -0,0 +1,17 @@ +import type { OnAssetsConversionHandler } from '@metamask/snaps-sdk'; + +import { tokenPricesService } from '../../../snapContext'; +import logger from '../../utils/logger'; + +export const onAssetsConversion: OnAssetsConversionHandler = async (params) => { + logger.log('[💱 onAssetsConversion]', params); + + const { conversions } = params; + + const conversionRates = + await tokenPricesService.getMultipleTokenConversions(conversions); + + return { + conversionRates, + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsLookup/onAssetsLookup.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsLookup/onAssetsLookup.ts new file mode 100644 index 00000000..5d43b213 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsLookup/onAssetsLookup.ts @@ -0,0 +1,30 @@ +import type { CaipAssetType, FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import type { OnAssetsLookupHandler } from '@metamask/snaps-sdk'; +import { parseCaipAssetType } from '@metamask/utils'; + +import { assetsService } from '../../../snapContext'; +import type { + NativeCaipAssetType, + TokenCaipAssetType, +} from '../../constants/solana'; +import logger from '../../utils/logger'; + +export const onAssetsLookup: OnAssetsLookupHandler = async (params) => { + logger.log('[🔍 onAssetsLookup]', params); + + const { assets } = params; + + /** + * TODO: Remove me when we have the new version of Snaps SDK + */ + const fungibleAssets = assets.filter((asset) => { + const { assetNamespace } = parseCaipAssetType(asset); + return assetNamespace === 'token' || assetNamespace === 'slip44'; + }) as (TokenCaipAssetType | NativeCaipAssetType)[]; + + const metadata = (await assetsService.getAssetsMetadata( + fungibleAssets, + )) as Record; + + return { assets: metadata }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.test.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.test.ts new file mode 100644 index 00000000..e3ee6c1e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.test.ts @@ -0,0 +1,358 @@ +import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; + +import { assetsService } from '../../../snapContext'; +import logger from '../../utils/logger'; +import { onAssetsMarketData } from './onAssetsMarketData'; + +jest.mock('../../../snapContext', () => ({ + assetsService: { + fetchAssetsMarketData: jest.fn(), + }, +})); + +jest.mock('../../utils/logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +describe('onAssetsMarketData', () => { + const mockAssetsService = assetsService as jest.Mocked; + + const BTC = + 'bip122:000000000019d6689c085ae165831e93/slip44:0' as CaipAssetType; + const ETH = 'eip155:1/slip44:60' as CaipAssetType; + const SOL = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType; + const USD = 'swift:0/iso4217:USD' as CaipAssetType; + const EUR = 'swift:0/iso4217:EUR' as CaipAssetType; + + const PT1H = 'PT1H'; + const P1D = 'P1D'; + const P7D = 'P7D'; + const P30D = 'P30D'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('successful scenarios', () => { + it('should return market data for crypto assets in USD', async () => { + const params = { + assets: [ + { asset: BTC, unit: USD }, + { asset: ETH, unit: USD }, + { asset: SOL, unit: USD }, + ], + }; + + const mockMarketData: Record = { + [BTC]: { + fungible: true, + marketCap: '1000000000000', + totalVolume: '50000000000', + circulatingSupply: '19500000', + allTimeHigh: '120000', + allTimeLow: '67.81', + pricePercentChange: { + [PT1H]: 0.5, + [P1D]: 2.1, + [P7D]: -1.2, + [P30D]: 15.3, + }, + }, + [ETH]: { + fungible: true, + marketCap: '400000000000', + totalVolume: '20000000000', + circulatingSupply: '120000000', + allTimeHigh: '5000', + allTimeLow: '0.43', + pricePercentChange: { + [PT1H]: 1.2, + [P1D]: 3.5, + [P7D]: 5.1, + }, + }, + [SOL]: { + fungible: true, + marketCap: '80000000000', + totalVolume: '3000000000', + circulatingSupply: '400000000', + allTimeHigh: '260', + allTimeLow: '0.5', + pricePercentChange: { + [PT1H]: -0.8, + [P1D]: 1.5, + [P7D]: -2.3, + }, + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should return market data for crypto assets in different fiat currencies', async () => { + const params = { + assets: [ + { asset: BTC, unit: EUR }, + { asset: ETH, unit: USD }, + ], + }; + + const mockMarketData: Record = { + [BTC]: { + fungible: true, + marketCap: '850000000000', + totalVolume: '42500000000', + circulatingSupply: '19500000', + allTimeHigh: '102000', + allTimeLow: '57.64', + pricePercentChange: { + [PT1H]: 0.3, + [P1D]: 1.8, + }, + }, + [ETH]: { + fungible: true, + marketCap: '400000000000', + totalVolume: '20000000000', + circulatingSupply: '120000000', + allTimeHigh: '5000', + allTimeLow: '0.43', + pricePercentChange: { + [PT1H]: 1.2, + [P1D]: 3.5, + }, + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should return market data with minimal fields when some data is missing', async () => { + const params = { + assets: [{ asset: BTC, unit: USD }], + }; + + const mockMarketData: Record = { + [BTC]: { + fungible: true, + marketCap: '1000000000000', + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should return empty market data when no assets are provided', async () => { + const params = { + assets: [], + }; + + const mockMarketData: Record = {}; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should return market data with only price percent changes when other fields are null', async () => { + const params = { + assets: [{ asset: BTC, unit: USD }], + }; + + const mockMarketData: Record = { + [BTC]: { + fungible: true, + pricePercentChange: { + [PT1H]: 0.5, + [P1D]: 2.1, + [P7D]: -1.2, + }, + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + }); + + describe('edge cases', () => { + it('should handle assets with special characters in asset types', async () => { + const specialAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501' as CaipAssetType; + const params = { + assets: [{ asset: specialAsset, unit: USD }], + }; + + const mockMarketData: Record = { + [specialAsset]: { + fungible: true, + marketCap: '50000000', + totalVolume: '2000000', + circulatingSupply: '1000000', + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should handle very large numbers in market data', async () => { + const params = { + assets: [{ asset: BTC, unit: USD }], + }; + + const mockMarketData: Record = { + [BTC]: { + fungible: true, + marketCap: '999999999999999999999999999999', + totalVolume: '123456789012345678901234567890', + circulatingSupply: '21000000', + allTimeHigh: '999999999999999999999999999999', + allTimeLow: '0.000000000000000001', + pricePercentChange: { + [PT1H]: 999.99, + [P1D]: -999.99, + }, + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + + it('should handle zero values in market data', async () => { + const params = { + assets: [{ asset: ETH, unit: USD }], + }; + + const mockMarketData: Record = { + [ETH]: { + fungible: true, + marketCap: '0', + totalVolume: '0', + circulatingSupply: '0', + allTimeHigh: '0', + allTimeLow: '0', + pricePercentChange: { + [PT1H]: 0, + [P1D]: 0, + [P7D]: 0, + }, + }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + const result = await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + expect(mockAssetsService.fetchAssetsMarketData).toHaveBeenCalledWith( + params.assets, + ); + expect(result).toStrictEqual({ marketData: mockMarketData }); + }); + }); + + describe('logging behavior', () => { + it('should log the input parameters correctly', async () => { + const params = { + assets: [ + { asset: BTC, unit: USD }, + { asset: ETH, unit: EUR }, + ], + }; + + const mockMarketData: Record = { + [BTC]: { fungible: true }, + [ETH]: { fungible: true }, + }; + + mockAssetsService.fetchAssetsMarketData.mockResolvedValue(mockMarketData); + + await onAssetsMarketData(params); + + expect(logger.log).toHaveBeenCalledWith( + '[💰 onAssetsMarketData]', + params, + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.ts new file mode 100644 index 00000000..02143793 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onAssetsMarketData/onAssetsMarketData.ts @@ -0,0 +1,14 @@ +import type { OnAssetsMarketDataHandler } from '@metamask/snaps-sdk'; + +import { assetsService } from '../../../snapContext'; +import logger from '../../utils/logger'; + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async (params) => { + logger.log('[💰 onAssetsMarketData]', params); + + const { assets } = params; + + const marketData = await assetsService.fetchAssetsMarketData(assets); + + return { marketData }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts new file mode 100644 index 00000000..94862d9b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts @@ -0,0 +1,999 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import { getBase64Codec, getUtf8Codec, pipe } from '@solana/kit'; + +import { KnownCaip19Id, Network } from '../../constants/solana'; +import type { AccountsService, ApproveTokenService } from '../../services'; +import type { SendService } from '../../services/send/SendService'; +import type { ValidationResponse } from '../../services/send/types'; +import type { SolanaSignAndSendTransactionResponse } from '../../services/wallet/structs'; +import type { WalletService } from '../../services/wallet/WalletService'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import type { ILogger } from '../../utils/logger'; +import { ClientRequestHandler } from './ClientRequestHandler'; +import { ClientRequestMethod } from './types'; + +// Mock the compileTransaction function +jest.mock('@solana/kit', () => { + const actual = jest.requireActual('@solana/kit'); + return { + ...actual, + compileTransaction: jest.fn().mockReturnValue({ + messageBytes: new Uint8Array([1, 2, 3]), + signatures: {}, + }), + }; +}); + +// Mock the fromTransactionToBase64String function +jest.mock('../../sdk-extensions/codecs', () => ({ + ...jest.requireActual('../../sdk-extensions/codecs'), + fromTransactionToBase64String: jest + .fn() + .mockReturnValue('mockBase64Transaction'), +})); + +describe('ClientRequestHandler', () => { + let handler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockLogger: jest.Mocked; + let sendService: jest.Mocked; + let mockApproveTokenService: jest.Mocked; + + beforeEach(() => { + // Create mock keyring + mockAccountsService = { + findById: jest.fn(), + findByAddress: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock wallet service + mockWalletService = { + signAndSendTransaction: jest.fn(), + signMessage: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock logger + mockLogger = { + log: jest.fn(), + } as unknown as jest.Mocked; + + sendService = { + onAmountInput: jest.fn(), + onAddressInput: jest.fn(), + confirmSend: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock approve token service + mockApproveTokenService = { + buildApprovalTransactionMessage: jest.fn(), + } as unknown as jest.Mocked; + + // Create handler instance + handler = new ClientRequestHandler( + mockAccountsService, + mockWalletService, + mockLogger, + sendService, + mockApproveTokenService, + ); + + // Reset all mocks + jest.clearAllMocks(); + }); + + describe('handle', () => { + describe('when request to method is not supported', () => { + it('should throw method not found error', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: 'unsupported_method', + params: [], + }; + + await expect(handler.handle(request)).rejects.toThrow( + 'The method does not exist / is not available.', + ); + }); + }); + }); + + describe('signAndSendTransactionWithoutConfirmation', () => { + const mockTransaction = + 'gAEAChfds67pR0IYlL1XKFGjzC+zBZIA7vT1dj7nUBTUwAs7rBn3hrWmXImk+UetGwvWumZpcEhF+xT5THD5os2Svp67H8GdfJ4jkOslaYOff/X0SeF33Cha5Ij9DKlo3QaosfIhtgnDmdJAVdjfmyv5dEGsSWgYEYPaqW8v8hSJozhYIzcQa4p5MinFjNMq4vDm+n249hE5Vmwe+Adkq87GTDegPbdaVhuqlrT5hZnIkdcW67eCFvm9ZzXM9jeWm2GwnLBGfnLp6qHLD5IgtqLXr5clzwoa2ns4KdysosGA2yFHt2dBBA/kB6qwUEagfnGzH2GY12XE3va/gn3W4Loqy/D+gP5PMjWwL4nGY8i3EGxqLHt/i3x/EskcO1ftQjYqQtqMVVDE+U/Vngb6x6+HbBOGrDQOx73j0k813TrK9dmptLgHDBoOIz6tGmWT+r9wa3YtqouLIv01/IKynGlc4TnE0M2MheikqA6gkuj1PhXVDgPc7eSLCseVMCy/WUgTmKbXmnZ/QcH3X8YTJ//GR4yvL7LCHdfHPhS8B+4hpoFusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FVRPixdukLDvYMw2r2DTumX5VA1ifoAVfXgkOTnLswDEoyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6LwHxW5grT0/F3OC6sZUj7of0yz9kMoCs+fPoYX9znOYyUOdJ8PoWqEFw7H8n7xwkhg1P1tPfo1/6arncTl4PJkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwan1RcYx3TJKFZjmGkdXraLXrijm0ttXHNVWyEAAAAA0LlMW0o1bBeKH7IjSDuI9G0a+7kgVaW9ubbU+rklS9sFDgIWFAkAi8sXoE0wggARAAUCuIEWABEACAMgoQcAAAAAEAYACAATDS0BARVDLQ8ABgUECCoTFRUSFSslKyIjBQooKiQrDy0tKSsMAwEVKyErHB0KBygsHisPLS0pKx8gFScPJhsHBBcZGi0JGAILFSzBIJszQdacgQMDAAAAJmQAASZkAQIaZAIDQEIPAAAAAAC78khRAQAAADIAAANOgLKSmCyUmuksqMclFoVdtmiFizz7/yF11zNd6TSAxgUkIB4dIwIhIrYRAfelfqMdEh4JHXx6VS3GXpyeWhNKQlBsx9m2I8c+BhMSEBEUWgDdfctSzc+t7n0tohMIoz7S6USQkKhKDRCUSx6C3SjhJQQJAg8EBgoMCwYQBw=='; + + const validRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + scope: Network.Testnet, + transaction: mockTransaction, + options: { + commitment: 'confirmed', + skipPreflight: false, + maxRetries: 3, + }, + }, + }; + + describe('when request is valid', () => { + beforeEach(() => { + mockAccountsService.findByAddress.mockResolvedValue( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + }); + + it('calls the wallet service and returns the response', async () => { + const expectedResponse: SolanaSignAndSendTransactionResponse = { + signature: 'transaction-signature', + }; + mockWalletService.signAndSendTransaction.mockResolvedValue( + expectedResponse, + ); + + const response = await handler.handle(validRequest); + + expect(mockWalletService.signAndSendTransaction).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + mockTransaction, + Network.Testnet, + 'metamask', + { + commitment: 'confirmed', + skipPreflight: false, + maxRetries: 3, + }, + ); + + expect(response).toStrictEqual(expectedResponse); + }); + + it('propagates wallet service errors', async () => { + const walletServiceError = new Error('Transaction failed'); + mockWalletService.signAndSendTransaction.mockRejectedValue( + walletServiceError, + ); + + await expect(handler.handle(validRequest)).rejects.toThrow( + 'Transaction failed', + ); + }); + }); + + describe('when the account is not found', () => { + it('throws an account not found error', async () => { + mockAccountsService.findByAddress.mockResolvedValue(null); + + await expect(handler.handle(validRequest)).rejects.toThrow( + `Account not found: ${MOCK_SOLANA_KEYRING_ACCOUNT_0.address}`, + ); + }); + }); + + describe('when the method is invalid', () => { + it('throws a method not found error', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: 'invalid_method', + params: [], + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'The method does not exist / is not available.', + ); + }); + }); + + describe('when the params are invalid', () => { + it('throws a invalid params error', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, + params: { + name: 'Alice', + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + }); + }); + + describe('signAndSendTransaction', () => { + const mockTransaction = + 'gAEAChfds67pR0IYlL1XKFGjzC+zBZIA7vT1dj7nUBTUwAs7rBn3hrWmXImk+UetGwvWumZpcEhF+xT5THD5os2Svp67H8GdfJ4jkOslaYOff/X0SeF33Cha5Ij9DKlo3QaosfIhtgnDmdJAVdjfmyv5dEGsSWgYEYPaqW8v8hSJozhYIzcQa4p5MinFjNMq4vDm+n249hE5Vmwe+Adkq87GTDegPbdaVhuqlrT5hZnIkdcW67eCFvm9ZzXM9jeWm2GwnLBGfnLp6qHLD5IgtqLXr5clzwoa2ns4KdysosGA2yFHt2dBBA/kB6qwUEagfnGzH2GY12XE3va/gn3W4Loqy/D+gP5PMjWwL4nGY8i3EGxqLHt/i3x/EskcO1ftQjYqQtqMVVDE+U/Vngb6x6+HbBOGrDQOx73j0k813TrK9dmptLgHDBoOIz6tGmWT+r9wa3YtqouLIv01/IKynGlc4TnE0M2MheikqA6gkuj1PhXVDgPc7eSLCseVMCy/WUgTmKbXmnZ/QcH3X8YTJ//GR4yvL7LCHdfHPhS8B+4hpoFusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FVRPixdukLDvYMw2r2DTumX5VA1ifoAVfXgkOTnLswDEoyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6LwHxW5grT0/F3OC6sZUj7of0yz9kMoCs+fPoYX9znOYyUOdJ8PoWqEFw7H8n7xwkhg1P1tPfo1/6arncTl4PJkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwan1RcYx3TJKFZjmGkdXraLXrijm0ttXHNVWyEAAAAA0LlMW0o1bBeKH7IjSDuI9G0a+7kgVaW9ubbU+rklS9sFDgIWFAkAi8sXoE0wggARAAUCuIEWABEACAMgoQcAAAAAEAYACAATDS0BARVDLQ8ABgUECCoTFRUSFSslKyIjBQooKiQrDy0tKSsMAwEVKyErHB0KBygsHisPLS0pKx8gFScPJhsHBBcZGi0JGAILFSzBIJszQdacgQMDAAAAJmQAASZkAQIaZAIDQEIPAAAAAAC78khRAQAAADIAAANOgLKSmCyUmuksqMclFoVdtmiFizz7/yF11zNd6TSAxgUkIB4dIwIhIrYRAfelfqMdEh4JHXx6VS3GXpyeWhNKQlBsx9m2I8c+BhMSEBEUWgDdfctSzc+t7n0tohMIoz7S6USQkKhKDRCUSx6C3SjhJQQJAg8EBgoMCwYQBw=='; + + const mockTransactionId = + '4x9nP9P4PnddXcfuahUBtGD3YgskhYMhCvswVZev7ZZW9U5no9H9QP33UoynXoiynH43RvHwoTBDLmv4hMFBv16w'; + + const validRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + scope: Network.Testnet, + transaction: mockTransaction, + options: { + commitment: 'confirmed', + skipPreflight: false, + maxRetries: 3, + }, + }, + }; + + describe('when request is valid', () => { + beforeEach(() => { + mockAccountsService.findById.mockResolvedValue( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + }); + + it('calls the wallet service and returns the response with transactionId', async () => { + const expectedResponse = { + transactionId: mockTransactionId, + }; + mockWalletService.signAndSendTransaction.mockResolvedValue({ + signature: expectedResponse.transactionId, + }); + + const response = await handler.handle(validRequest); + + expect(mockAccountsService.findById).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + expect(mockWalletService.signAndSendTransaction).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + mockTransaction, + Network.Testnet, + 'metamask', + { + commitment: 'confirmed', + skipPreflight: false, + maxRetries: 3, + }, + ); + + expect(response).toStrictEqual(expectedResponse); + }); + + it('propagates wallet service errors', async () => { + const walletServiceError = new Error('Transaction failed'); + mockWalletService.signAndSendTransaction.mockRejectedValue( + walletServiceError, + ); + + await expect(handler.handle(validRequest)).rejects.toThrow( + 'Transaction failed', + ); + }); + }); + + describe('when the account is not found', () => { + it('throws an account not found error', async () => { + mockAccountsService.findById.mockRejectedValue( + new Error('Account not found'), + ); + + await expect(handler.handle(validRequest)).rejects.toThrow( + 'Account not found', + ); + }); + }); + + describe('when the params are invalid', () => { + it('throws a invalid params error', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignAndSendTransaction, + params: { + name: 'Alice', + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + }); + }); + + describe('computeFee', () => { + const mockTransaction = + 'AXxLCXtYo9AY2f1GE1V0Pp7QoiFvY67WGMkRasmnGa4QI66lz0fk+z1O9hWggkEdSb4eCK+sSQOBgKffpW8nFwGAAQACBZmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N90qg5jUUNU1A79nvrnBM2x0tvjvM0bJFPOKZHbnqR9TzLfJCXvFr85xcLXaaIzbXdnbOFs+klSdfVRIluJ9yfUxwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKk9holmkkG74a11Z3XVXb69hHvVi8hHst59kfJlZt2iKAMDAAUCuhIAAAMACQPoAwAAAAAAAAQDAgEACQPoAwAAAAAAAAA='; + + const validRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ComputeFee, + params: { + transaction: mockTransaction, + accountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + scope: Network.Testnet, + }, + }; + + describe('when request is valid', () => { + it('calls the transaction helper and returns the base and priority fee', async () => { + const response = await handler.handle(validRequest); + + expect(response).toStrictEqual([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501', + amount: '0.000005', + fungible: true, + }, + }, + { + type: 'priority', + asset: { + unit: 'SOL', + type: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501', + amount: '0.000000004', + fungible: true, + }, + }, + ]); + }); + }); + + describe('when the params are invalid', () => { + it('throws a invalid params error', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ComputeFee, + params: { + name: 'Alice', + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + }); + }); + + describe('confirmSend', () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + amount: '10', + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + it('calls the send service and returns the response', async () => { + const response = { + pending: false, + result: { + signature: 'transaction-signature', + }, + }; + + jest.spyOn(sendService, 'confirmSend').mockResolvedValue(response); + + const result = await handler.handle(request); + + expect(sendService.confirmSend).toHaveBeenCalledWith(request); + expect(result).toStrictEqual(response); + }); + + it('propagates send service errors', async () => { + const sendServiceError = new Error('Transaction failed'); + jest + .spyOn(sendService, 'confirmSend') + .mockRejectedValue(sendServiceError); + + await expect(handler.handle(request)).rejects.toThrow( + 'Transaction failed', + ); + }); + + it('validates the request if invalid params are provided', async () => { + const invalidRequest: JsonRpcRequest = { + ...request, + params: { + fromAccountId: 'invalid-account-id', + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + amount: '10', + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); + }); + + describe('onAddressInput', () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { + value: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + scope: Network.Testnet, + }, + }; + + it('calls the send service and returns the response', async () => { + const response = { + valid: true, + errors: [], + }; + + jest.spyOn(sendService, 'onAddressInput').mockResolvedValue(response); + + const result = await handler.handle(request); + + expect(sendService.onAddressInput).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + Network.Testnet, + ); + expect(result).toStrictEqual(response); + }); + + it('defaults the scope to Mainnet if not provided', async () => { + const response = { + valid: true, + errors: [], + }; + + jest.spyOn(sendService, 'onAddressInput').mockResolvedValue(response); + + const requestWithNoScope: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { + value: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + }; + + const result = await handler.handle(requestWithNoScope); + + expect(sendService.onAddressInput).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + Network.Mainnet, + ); + expect(result).toStrictEqual(response); + }); + + it('throws an error if value is not provided', async () => { + const emptyValueRequest: JsonRpcRequest = { + ...request, + params: { + value: null, + }, + }; + + await expect(handler.handle(emptyValueRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); + + it('validates the response if invalid', async () => { + const response = { + property: 'invalid', + } as unknown as ValidationResponse; + + jest.spyOn(sendService, 'onAddressInput').mockResolvedValue(response); + + await expect(handler.handle(request)).rejects.toThrow(/At path: valid/iu); + }); + }); + + describe('onAmountInput', () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + value: '10', + accountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + it('calls the send service and returns the response', async () => { + const response = { + valid: true, + errors: [], + }; + + jest.spyOn(sendService, 'onAmountInput').mockResolvedValue(response); + + const result = await handler.handle(request); + + expect(sendService.onAmountInput).toHaveBeenCalledWith(request); + expect(result).toStrictEqual(response); + }); + + it('throws an error if value is not provided', async () => { + const emptyValueRequest: JsonRpcRequest = { + ...request, + params: { + value: null, + }, + }; + + await expect(handler.handle(emptyValueRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); + + it('validates the response if invalid', async () => { + const response = { + property: 'invalid', + } as unknown as ValidationResponse; + + jest.spyOn(sendService, 'onAmountInput').mockResolvedValue(response); + + await expect(handler.handle(request)).rejects.toThrow(/At path: valid/iu); + }); + }); + + describe('signRewardsMessage', () => { + // Helper function to convert a utf8 string to base64 + const utf8ToBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const { id: accountId, address } = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockTimestamp = 1736660000; + + // Helper function to create a request with a utf8 message. Defaults to a valid rewards message. + const createRequest = (utf8Message?: string): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId, + message: utf8ToBase64( + utf8Message ?? `rewards,${address},${mockTimestamp}`, + ), + }, + }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(mockTimestamp * 1000)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls the wallet service and returns the response', async () => { + const response = { + signature: + '61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu', + signedMessage: + 'cmV3YXJkcyxCTHczUndlSm1mYlRhcEpSZ25QUnZkOTYyWURqRllBblZHZDFwNWhtWjV0UCwxNzM2NjYwMDAw', + signatureType: 'ed25519' as const, + }; + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest.spyOn(mockWalletService, 'signMessage').mockResolvedValue(response); + + const request = createRequest(); + + const result = await handler.handle(request); + + expect(mockWalletService.signMessage).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + 'cmV3YXJkcyxCTHczUndlSm1mYlRhcEpSZ25QUnZkOTYyWURqRllBblZHZDFwNWhtWjV0UCwxNzM2NjYwMDAw', + ); + expect(result).toStrictEqual(response); + }); + + it('throws an error if message is invalid', async () => { + const invalidMessageRequest = createRequest('invalid-message'); + + await expect(handler.handle(invalidMessageRequest)).rejects.toThrow( + 'Message must start with', + ); + }); + + it('throws an error if account is not found', async () => { + const invalidAccountRequest = createRequest(); + mockAccountsService.findById.mockResolvedValue(null); + + await expect(handler.handle(invalidAccountRequest)).rejects.toThrow( + 'Account not found', + ); + }); + + it('throws an error if address in message does not match signing account', async () => { + const signingAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + mockAccountsService.findById.mockResolvedValue(signingAccount); + + // Use a valid Solana address format but different from the signing account + const differentAddress = MOCK_SOLANA_KEYRING_ACCOUNT_1.address; + const requestWithDifferentAddress = createRequest( + `rewards,${differentAddress},${mockTimestamp}`, + ); + + await expect(handler.handle(requestWithDifferentAddress)).rejects.toThrow( + `Address in rewards message (${differentAddress}) does not match signing account address (${address})`, + ); + }); + }); + + describe('signProofOfOwnership', () => { + const utf8ToBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const { id: accountId, address } = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const nonce = 'a1b2c3d4e5f6789012345678'; + + const buildProofMessage = ( + proofNonce: string = nonce, + proofAddress: string = address, + ): string => `metamask:proof-of-ownership:${proofNonce}:${proofAddress}`; + + const createRequest = (message?: string): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: message ?? buildProofMessage(), + }, + }); + + it('signs the proof message and returns the signature as 0x-prefixed hex', async () => { + // 64 bytes of 0x01 in base58 — the wallet-standard format + // `WalletService.signMessage` returns. The handler must transcode + // this to 0x-prefixed hex for the identity auth API. + const base58Signature = + '2AXDGYSE4f2sz7tvMMzyHvUfcoJmxudvdhBcmiUSo6ijwfYmfZYsKRxboQMPh3R4kUhXRVdtSXFXMheka4Rc4P2'; + const expectedBase64Message = utf8ToBase64(buildProofMessage()); + + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest.spyOn(mockWalletService, 'signMessage').mockResolvedValue({ + signature: base58Signature, + signedMessage: expectedBase64Message, + signatureType: 'ed25519' as const, + }); + + const result = await handler.handle(createRequest()); + + expect(mockWalletService.signMessage).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + expectedBase64Message, + ); + expect(result).toStrictEqual({ signature: `0x${'01'.repeat(64)}` }); + }); + + it('throws if the message does not start with the proof prefix', async () => { + const invalidRequest = createRequest(`rewards,${address},1736660000`); + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Message must start with', + ); + }); + + it('throws if the account is not found', async () => { + mockAccountsService.findById.mockResolvedValue(null); + + await expect(handler.handle(createRequest())).rejects.toThrow( + 'Account not found', + ); + }); + + it('throws if the address in the message does not match the signing account', async () => { + mockAccountsService.findById.mockResolvedValue( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + const otherAddress = MOCK_SOLANA_KEYRING_ACCOUNT_1.address; + const requestWithDifferentAddress = createRequest( + buildProofMessage(nonce, otherAddress), + ); + + await expect(handler.handle(requestWithDifferentAddress)).rejects.toThrow( + `Address in proof-of-ownership message (${otherAddress}) does not match signing account address (${address})`, + ); + }); + + it('propagates errors from the wallet service', async () => { + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest + .spyOn(mockWalletService, 'signMessage') + .mockRejectedValue(new Error('signer unavailable')); + + await expect(handler.handle(createRequest())).rejects.toThrow( + 'signer unavailable', + ); + }); + }); + + describe('signCardMessage', () => { + // Helper function to convert a utf8 string to base64 + const utf8ToBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const { id: accountId, address } = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + // Create a valid SIWS-style card message + const createCardMessage = ( + signerAddress = address, + nonce = 'a90TLFMbDFGDWUTfs', + includeExpirationTime = true, + ): string => { + const base = `approve.card.metamask.io wants you to sign in with your Solana account: ${signerAddress} Prove address ownership URI: https://approve.card.metamask.io Version: 1 Chain ID: 1 Nonce: ${nonce} Issued At: 2025-12-02T14:25:49.589Z`; + return includeExpirationTime + ? `${base} Expiration Time: 2025-12-02T14:35:49.589Z` + : base; + }; + + const createRequest = (message: string): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignCardMessage, + params: { + accountId, + message: utf8ToBase64(message), + }, + }); + + it('calls the wallet service and returns the response', async () => { + const validMessage = createCardMessage(); + const base64Message = utf8ToBase64(validMessage); + + const response = { + signature: + '61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu', + signedMessage: base64Message, + signatureType: 'ed25519' as const, + }; + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest.spyOn(mockWalletService, 'signMessage').mockResolvedValue(response); + + const request = createRequest(validMessage); + + const result = await handler.handle(request); + + expect(mockWalletService.signMessage).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + base64Message, + ); + expect(result).toStrictEqual(response); + }); + + it('throws an error if account is not found', async () => { + mockAccountsService.findById.mockResolvedValue(null); + + const request = createRequest(createCardMessage()); + + await expect(handler.handle(request)).rejects.toThrow( + 'Account not found', + ); + }); + + it('accepts a valid message in newline-separated format', async () => { + const newlineMessage = `approve.card.metamask.io wants you to sign in with your Solana account:\n${address}\n\nProve address ownership\n\nURI: https://approve.card.metamask.io\nVersion: 1\nChain ID: 1\nNonce: cc665387ddce2831\nIssued At: 2026-03-18T13:16:54.855Z`; + const base64Message = utf8ToBase64(newlineMessage); + + const response = { + signature: + '61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu', + signedMessage: base64Message, + signatureType: 'ed25519' as const, + }; + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest.spyOn(mockWalletService, 'signMessage').mockResolvedValue(response); + + const request = createRequest(newlineMessage); + const result = await handler.handle(request); + + expect(result).toStrictEqual(response); + }); + + it('accepts a valid message without Expiration Time', async () => { + const messageWithoutExpiration = createCardMessage( + address, + 'a90TLFMbDFGDWUTfs', + false, + ); + const base64Message = utf8ToBase64(messageWithoutExpiration); + + const response = { + signature: + '61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu', + signedMessage: base64Message, + signatureType: 'ed25519' as const, + }; + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest.spyOn(mockWalletService, 'signMessage').mockResolvedValue(response); + + const request = createRequest(messageWithoutExpiration); + const result = await handler.handle(request); + + expect(result).toStrictEqual(response); + }); + + it('throws an error if message format is invalid', async () => { + const invalidMessageRequest = createRequest('invalid-message'); + + await expect(handler.handle(invalidMessageRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + + it('throws an error if address in message does not match signing account', async () => { + const signingAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + mockAccountsService.findById.mockResolvedValue(signingAccount); + + // Use a valid Solana address format but different from the signing account + const differentAddress = MOCK_SOLANA_KEYRING_ACCOUNT_1.address; + const requestWithDifferentAddress = createRequest( + createCardMessage(differentAddress), + ); + + await expect(handler.handle(requestWithDifferentAddress)).rejects.toThrow( + `Address in card message (${differentAddress}) does not match signing account address (${address})`, + ); + }); + + it('throws an error if params are invalid', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignCardMessage, + params: { + accountId: 'invalid-uuid', + message: 'test', + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + }); + + describe('approveCardAmount', () => { + const { id: accountId } = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC mint + const mockDelegate = '4jepDb74FCMr1wgoSA34FeJ2mkvEsJBRZQQRumqp9EL3'; + + const createRequest = ( + amount: string, + mint = mockMint, + delegate = mockDelegate, + scope = Network.Mainnet, + ): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ApproveCardAmount, + params: { + accountId, + amount, + mint, + delegate, + scope, + }, + }); + + it('builds and signs a token approval transaction', async () => { + const mockSignature = + '61Go4ycewVBbfpDSP6hSad567y3USmUHbfR19wC2PA8uHEFGtWPpjyZnLrfH2yKLYkG7ezwT7jdE95NsVKUe1JNu'; + + // Mock the approve token service to return a transaction message + mockApproveTokenService.buildApprovalTransactionMessage.mockResolvedValue( + {} as any, + ); + + jest + .spyOn(mockAccountsService, 'findById') + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + jest + .spyOn(mockWalletService, 'signAndSendTransaction') + .mockResolvedValue({ + signature: mockSignature, + }); + + const request = createRequest('100.50'); + + const result = await handler.handle(request); + + expect(mockAccountsService.findById).toHaveBeenCalledWith(accountId); + expect( + mockApproveTokenService.buildApprovalTransactionMessage, + ).toHaveBeenCalledWith({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '100.50', + network: Network.Mainnet, + }); + expect(mockWalletService.signAndSendTransaction).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + expect.any(String), + Network.Mainnet, + 'metamask', + ); + expect(result).toStrictEqual({ signature: mockSignature }); + }); + + it('throws an error if account is not found', async () => { + mockAccountsService.findById.mockResolvedValue(null); + + const request = createRequest('100'); + + await expect(handler.handle(request)).rejects.toThrow( + 'Account not found', + ); + }); + + it('throws an error if amount is invalid', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ApproveCardAmount, + params: { + accountId, + amount: '-100', + mint: mockMint, + delegate: mockDelegate, + scope: Network.Mainnet, + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + + it('throws an error if params are missing', async () => { + const invalidRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ApproveCardAmount, + params: { + accountId, + }, + }; + + await expect(handler.handle(invalidRequest)).rejects.toThrow( + 'Invalid method parameter(s).', + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts new file mode 100644 index 00000000..bf56dd90 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -0,0 +1,507 @@ +import { FeeType } from '@metamask/keyring-api'; +import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { assert, create } from '@metamask/superstruct'; +import { bytesToHex } from '@metamask/utils'; +import { + address as asAddress, + compileTransaction, + getBase58Codec, + getBase64Codec, + getUtf8Codec, + pipe, +} from '@solana/kit'; + +import { METAMASK_ORIGIN, Networks } from '../../constants/solana'; +import type { Network } from '../../constants/solana'; +import { FeeCalculator } from '../../fees'; +import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; +import type { AccountsService, ApproveTokenService } from '../../services'; +import type { SendService } from '../../services/send/SendService'; +import type { OnAddressInputRequest } from '../../services/send/types'; +import type { WalletService } from '../../services/wallet/WalletService'; +import { lamportsToSol } from '../../utils/conversion'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { ClientRequestMethod } from './types'; +import { + ApproveCardAmountRequestStruct, + ApproveCardAmountResponseStruct, + ComputeFeeRequestStruct, + ComputeFeeResponseStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + OnConfirmSendRequestStruct, + parseCardMessage, + parseProofOfOwnershipMessage, + parseRewardsMessage, + SignAndSendTransactionRequestStruct, + SignAndSendTransactionResponseStruct, + SignAndSendTransactionWithoutConfirmationRequestStruct, + SignCardMessageRequestStruct, + SignProofOfOwnershipRequestStruct, + SignProofOfOwnershipResponseStruct, + SignRewardsMessageRequestStruct, + ValidationResponseStruct, +} from './validation'; +import type { + ComputeFeeResponse, + SignAndSendTransactionResponse, + SignProofOfOwnershipResponse, +} from './validation'; + +export class ClientRequestHandler { + readonly #accountsService: AccountsService; + + readonly #walletService: WalletService; + + readonly #logger: ILogger; + + readonly #sendService: SendService; + + readonly #approveTokenService: ApproveTokenService; + + constructor( + accountsService: AccountsService, + walletService: WalletService, + logger: ILogger, + sendService: SendService, + approveTokenService: ApproveTokenService, + ) { + this.#accountsService = accountsService; + this.#walletService = walletService; + this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); + this.#sendService = sendService; + this.#approveTokenService = approveTokenService; + } + + /** + * Handles JSON-RPC requests originating exclusively from the client - as defined in [SIP-31](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-31.md) - + * by routing them to the appropriate use case, based on the method. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {MethodNotFoundError} If the method is not found. + * @throws {InvalidParamsError} If the params are invalid. + */ + async handle(request: JsonRpcRequest): Promise { + this.#logger.log('Handling client request', request); + + const { method } = request; + + switch (method) { + // TODO: Deprecate this method in the next major version + case ClientRequestMethod.SignAndSendTransactionWithoutConfirmation: + return this.#handleSignAndSendWithoutConfirmation(request); + + case ClientRequestMethod.ConfirmSend: + return this.#handleConfirmSend(request); + case ClientRequestMethod.SignAndSendTransaction: + return this.#handleSignAndSendTransaction(request); + case ClientRequestMethod.ComputeFee: + return this.#handleComputeFee(request); + case ClientRequestMethod.OnAddressInput: + return this.#handleOnAddressInput(request); + case ClientRequestMethod.OnAmountInput: + return this.#handleOnAmountInput(request); + case ClientRequestMethod.SignRewardsMessage: + return this.#handleSignRewardsMessage(request); + case ClientRequestMethod.SignCardMessage: + return this.#handleSignCardMessage(request); + case ClientRequestMethod.ApproveCardAmount: + return this.#handleApproveCardAmount(request); + case ClientRequestMethod.SignProofOfOwnership: + return this.#handleSignProofOfOwnership(request); + default: + throw new MethodNotFoundError() as Error; + } + } + + /** + * Handles signing and sending a transaction without confirmation. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + * @deprecated + */ + async #handleSignAndSendWithoutConfirmation( + request: JsonRpcRequest, + ): Promise { + try { + assert(request, SignAndSendTransactionWithoutConfirmationRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { + transaction: base64EncodedTransaction, + options, + account: { address }, + scope, + }, + } = request; + + const account = await this.#accountsService.findByAddress(address); + if (!account) { + throw new InvalidParamsError(`Account not found: ${address}`) as Error; + } + + return this.#walletService.signAndSendTransaction( + account, + base64EncodedTransaction, + scope, + METAMASK_ORIGIN, + options, + ); + } + + async #handleSignAndSendTransaction(request: JsonRpcRequest): Promise { + try { + assert(request, SignAndSendTransactionRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { + transaction: base64EncodedTransaction, + options, + accountId, + scope, + }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + const { signature } = await this.#walletService.signAndSendTransaction( + account, + base64EncodedTransaction, + scope, + METAMASK_ORIGIN, + options, + ); + + const result: SignAndSendTransactionResponse = { + transactionId: signature, + }; + + assert(result, SignAndSendTransactionResponseStruct); + + return result; + } + + /** + * Handles the confirmation of a send transaction. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleConfirmSend(request: JsonRpcRequest): Promise { + try { + assert(request, OnConfirmSendRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + const result = await this.#sendService.confirmSend(request); + + return result; + } + + /** + * Handles the computation of a fee for a transaction. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleComputeFee(request: JsonRpcRequest): Promise { + try { + assert(request, ComputeFeeRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { transaction, scope }, + } = request; + + const { baseFee, priorityFee } = FeeCalculator.calculateFee(transaction); + + const units = Networks[scope].nativeToken.symbol; + const type = Networks[scope].nativeToken.caip19Id; + + const result: ComputeFeeResponse = [ + { + type: FeeType.Base, + asset: { + unit: units, + type, + amount: lamportsToSol(baseFee).toString(), + fungible: true, + }, + }, + { + type: FeeType.Priority, + asset: { + unit: units, + type, + amount: lamportsToSol(priorityFee).toString(), + fungible: true, + }, + }, + ]; + + assert(result, ComputeFeeResponseStruct); + + return result; + } + + /** + * Handles the input of an address. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleOnAddressInput(request: JsonRpcRequest): Promise { + let parsed: OnAddressInputRequest; + try { + parsed = create(request, OnAddressInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { value, scope }, + } = parsed; + const result = await this.#sendService.onAddressInput(value, scope); + + assert(result, ValidationResponseStruct); + + return result; + } + + /** + * Handles the input of an amount. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleOnAmountInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnAmountInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const result = await this.#sendService.onAmountInput(request); + + assert(result, ValidationResponseStruct); + + return result; + } + + /** + * Handles the signing of a rewards message, of format `'rewards,{address},{timestamp}'` base64 encoded. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the account is not found or if the address in the message doesn't match the signing account. + */ + async #handleSignRewardsMessage(request: JsonRpcRequest): Promise { + assert(request, SignRewardsMessageRequestStruct); + + const { + params: { accountId, message }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + // Parse the rewards message to extract the address + const { address: messageAddress } = parseRewardsMessage(message); + + // Validate that the address in the message matches the signing account + if (messageAddress !== account.address) { + throw new InvalidParamsError( + `Address in rewards message (${messageAddress}) does not match signing account address (${account.address})`, + ) as Error; + } + + const result = await this.#walletService.signMessage(account, message); + + return result; + } + + /** + * Handles the signing of a card message in SIWS (Sign-In with Solana) format. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the account is not found or if the address in the message doesn't match the signing account. + */ + async #handleSignCardMessage(request: JsonRpcRequest): Promise { + try { + assert(request, SignCardMessageRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { accountId, message }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + // Parse the card message to extract the address + const { address: messageAddress } = parseCardMessage(message); + + // Validate that the address in the message matches the signing account + if (messageAddress !== account.address) { + throw new InvalidParamsError( + `Address in card message (${messageAddress}) does not match signing account address (${account.address})`, + ) as Error; + } + + const result = await this.#walletService.signMessage(account, message); + + return result; + } + + /** + * Handles the approval of a card amount by creating and signing a token approval transaction. + * This creates an SPL token `approve` instruction that allows the delegate to spend tokens + * from the user's associated token account. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response containing the transaction ID. + * @throws {InvalidParamsError} If the account is not found or params are invalid. + */ + async #handleApproveCardAmount(request: JsonRpcRequest): Promise { + try { + assert(request, ApproveCardAmountRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { accountId, amount, mint, delegate, scope }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + const network = scope; + + // Build the approval transaction message using the service + const transactionMessage = + await this.#approveTokenService.buildApprovalTransactionMessage({ + account, + mint: asAddress(mint), + delegate: asAddress(delegate), + amount, + network, + }); + + // Compile and encode the transaction + const transaction = compileTransaction(transactionMessage); + const base64EncodedTransaction = fromTransactionToBase64String(transaction); + + // Sign and send the transaction + const { signature } = await this.#walletService.signAndSendTransaction( + account, + base64EncodedTransaction, + network, + METAMASK_ORIGIN, + ); + + const result = { signature }; + + assert(result, ApproveCardAmountResponseStruct); + + return result; + } + + /** + * Handles the silent signing of a proof-of-ownership message, of format `'metamask:proof-of-ownership:{nonce}:{address}'`. + * Used by `@metamask/profile-metrics-controller` to prove wallet control of an address; no user prompt, gated to the `metamask` origin (see `metamaskPermissions`) and bound to the message prefix. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the account is not found or if the address in the message doesn't match the signing account. + */ + async #handleSignProofOfOwnership(request: JsonRpcRequest): Promise { + assert(request, SignProofOfOwnershipRequestStruct); + + const { + params: { accountId, message }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + // Parse the proof-of-ownership message to extract the address + const { address: messageAddress } = parseProofOfOwnershipMessage(message); + + // Validate that the address in the message matches the signing account + if (messageAddress !== account.address) { + throw new InvalidParamsError( + `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${account.address})`, + ) as Error; + } + + // `WalletService.signMessage` expects a base64-encoded message + const base64Message = pipe( + message, + getUtf8Codec().encode, + getBase64Codec().decode, + ); + + const { signature: base58Signature } = + await this.#walletService.signMessage(account, base64Message); + + // Transcode the base58 signature to 0x-prefixed hex for the identity + // auth API; the dApp `signMessage` flow keeps its wallet-standard base58. + const signature = bytesToHex( + Uint8Array.from(getBase58Codec().encode(base58Signature)), + ); + + const result: SignProofOfOwnershipResponse = { signature }; + + assert(result, SignProofOfOwnershipResponseStruct); + + return result; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/index.ts new file mode 100644 index 00000000..57acf9b9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/index.ts @@ -0,0 +1,2 @@ +export * from './ClientRequestHandler'; +export * from './types'; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts new file mode 100644 index 00000000..2f220b2b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts @@ -0,0 +1,12 @@ +export enum ClientRequestMethod { + SignAndSendTransactionWithoutConfirmation = 'signAndSendTransactionWithoutConfirmation', + ConfirmSend = 'confirmSend', + SignAndSendTransaction = 'signAndSendTransaction', + ComputeFee = 'computeFee', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', + SignRewardsMessage = 'signRewardsMessage', + SignCardMessage = 'signCardMessage', + ApproveCardAmount = 'approveCardAmount', + SignProofOfOwnership = 'signProofOfOwnership', +} diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts new file mode 100644 index 00000000..ebf66a2b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts @@ -0,0 +1,386 @@ +import { assert, is } from '@metamask/superstruct'; +import { getBase64Codec, getUtf8Codec, pipe } from '@solana/kit'; + +import { + CardMessageStruct, + parseCardMessage, + parseProofOfOwnershipMessage, + ProofOfOwnershipMessageStruct, + RewardsMessageStruct, +} from './validation'; + +describe('validation', () => { + describe('RewardsMessageStruct', () => { + const validSolanaAddress = '5F9jaU8pWmLJxCk3dHvC1e7d1sWQ9H2kgvA5g4TtK9hF'; + const currentTimestamp = Math.floor(Date.now() / 1000); + + const toBase64 = (utf8: string): string => + pipe( + utf8, + getUtf8Codec().encode, // From uint8Array to utf8 + getBase64Codec().decode, // From base64 to uint8Array + ); + + it.each([ + `rewards,${validSolanaAddress},${currentTimestamp}`, + `rewards,${validSolanaAddress},${currentTimestamp - 30}`, // 30 seconds ago + `rewards,${validSolanaAddress},${currentTimestamp + 30}`, // 30 seconds in future + `rewards,BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP,${currentTimestamp}`, // Different valid address + ])('validates valid messages: "%s"', (utf8Message) => { + const message = toBase64(utf8Message); + expect(() => assert(message, RewardsMessageStruct)).not.toThrow(); + expect(is(message, RewardsMessageStruct)).toBe(true); + }); + + it.each([ + 'invalid-base64!', // Invalid characters + 'abc@def', // Invalid characters + 'hello world', // Spaces not allowed in base64 + '', // Empty string + 'abc', // Invalid padding + ])( + 'rejects messages with invalid base64 encoding: "%s"', + (base64Message) => { + expect(() => assert(base64Message, RewardsMessageStruct)).toThrow( + 'Expected a string matching', + ); + expect(is(base64Message, RewardsMessageStruct)).toBe(false); + }, + ); + + it.each([ + `reward,${validSolanaAddress},${currentTimestamp}`, // Missing 's' + `Rewards,${validSolanaAddress},${currentTimestamp}`, // Wrong case + `bonus,${validSolanaAddress},${currentTimestamp}`, // Wrong prefix + `${validSolanaAddress},${currentTimestamp}`, // No prefix + ])( + 'rejects messages that do not start with "rewards,": "%s"', + (utf8Message) => { + const message = toBase64(utf8Message); + expect(() => assert(message, RewardsMessageStruct)).toThrow( + 'Message must start with "rewards,"', + ); + expect(is(message, RewardsMessageStruct)).toBe(false); + }, + ); + + it.each([ + 'rewards,', // Only prefix + `rewards,${validSolanaAddress}`, // Missing timestamp + `rewards,${validSolanaAddress},${currentTimestamp},extra`, // Too many parts + 'rewards,,,', // Empty parts + ])( + 'rejects messages with incorrect number of parts: "%s"', + (utf8Message) => { + const message = toBase64(utf8Message); + expect(() => assert(message, RewardsMessageStruct)).toThrow( + 'Message must have exactly 3 parts', + ); + expect(is(message, RewardsMessageStruct)).toBe(false); + }, + ); + + it.each([ + `rewards,invalid-address,${currentTimestamp}`, + `rewards,,${currentTimestamp}`, // Empty address + `rewards,0x1234567890abcdef1234567890abcdef12345678,${currentTimestamp}`, // Ethereum address + `rewards,too-short,${currentTimestamp}`, // Too short + `rewards,this-address-is-way-too-long-to-be-a-valid-solana-address,${currentTimestamp}`, // Too long + ])( + 'rejects messages with invalid Solana addresses: "%s"', + (utf8Message) => { + const message = toBase64(utf8Message); + expect(() => assert(message, RewardsMessageStruct)).toThrow( + 'Invalid Solana address', + ); + expect(is(message, RewardsMessageStruct)).toBe(false); + }, + ); + + it.each([ + `rewards,${validSolanaAddress},invalid`, // Non-numeric + `rewards,${validSolanaAddress},`, // Empty timestamp + `rewards,${validSolanaAddress},-1`, // Negative timestamp + `rewards,${validSolanaAddress},0`, // Zero timestamp + `rewards,${validSolanaAddress},123.456`, // Decimal timestamp + ])('rejects messages with invalid timestamps: "%s"', (utf8Message) => { + const message = toBase64(utf8Message); + + expect(() => assert(message, RewardsMessageStruct)).toThrow( + 'Invalid timestamp', + ); + expect(is(message, RewardsMessageStruct)).toBe(false); + }); + + it.each([ + 123, // Number + null, // Null + undefined, // Undefined + {}, // Object + [], // Array + true, // Boolean + ])('rejects non-string values: "%s"', (value) => { + expect(() => assert(value, RewardsMessageStruct)).toThrow( + 'Expected a string', + ); + expect(is(value, RewardsMessageStruct)).toBe(false); + }); + }); + + describe('parseCardMessage', () => { + const validAddress = '5F9jaU8pWmLJxCk3dHvC1e7d1sWQ9H2kgvA5g4TtK9hF'; + const toBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const buildMessage = (overrides: Record = {}): string => { + const fields: Record = { + domain: 'example.com', + address: validAddress, + statement: 'Please sign this message.', + uri: 'https://example.com', + version: '1', + chainId: '1', + nonce: 'abc123', + issuedAt: '2024-01-01T00:00:00.000Z', + ...overrides, + }; + return [ + `${fields.domain} wants you to sign in with your Solana account:`, + fields.address, + fields.statement, + `URI: ${fields.uri}`, + `Version: ${fields.version}`, + `Chain ID: ${fields.chainId}`, + `Nonce: ${fields.nonce}`, + `Issued At: ${fields.issuedAt}`, + ...(fields.expirationTime + ? [`Expiration Time: ${fields.expirationTime}`] + : []), + ].join('\n'); + }; + + it('parses a valid message without expiration time', () => { + const result = parseCardMessage(toBase64(buildMessage())); + expect(result).toStrictEqual({ + domain: 'example.com', + address: validAddress, + statement: 'Please sign this message.', + uri: 'https://example.com', + version: '1', + chainId: '1', + nonce: 'abc123', + issuedAt: '2024-01-01T00:00:00.000Z', + }); + }); + + it('parses a valid message with expiration time', () => { + const result = parseCardMessage( + toBase64(buildMessage({ expirationTime: '2024-01-02T00:00:00.000Z' })), + ); + expect(result).toStrictEqual({ + domain: 'example.com', + address: validAddress, + statement: 'Please sign this message.', + uri: 'https://example.com', + version: '1', + chainId: '1', + nonce: 'abc123', + issuedAt: '2024-01-01T00:00:00.000Z', + expirationTime: '2024-01-02T00:00:00.000Z', + }); + }); + + it('parses a message with a multi-line statement', () => { + const result = parseCardMessage( + toBase64(buildMessage({ statement: 'Line one.\nLine two.' })), + ); + expect(result.statement).toBe('Line one.\nLine two.'); + }); + + it('parses a message with an alphanumeric nonce', () => { + const result = parseCardMessage( + toBase64(buildMessage({ nonce: 'Nonce1ABC2xyz3' })), + ); + expect(result.nonce).toBe('Nonce1ABC2xyz3'); + }); + + it('throws for invalid message format', () => { + expect(() => + parseCardMessage(toBase64('not a valid siws message')), + ).toThrow('Invalid card message format'); + }); + + it('throws for an address with invalid Base58 characters', () => { + expect(() => + parseCardMessage( + toBase64( + buildMessage({ + address: '0x1234567890abcdef1234567890abcdef12345678', + }), + ), + ), + ).toThrow('Invalid Solana address in card message'); + }); + + it('throws for invalid URI', () => { + expect(() => + parseCardMessage(toBase64(buildMessage({ uri: 'not-a-uri' }))), + ).toThrow('Invalid URI in card message'); + }); + + it('throws for invalid Issued At date', () => { + expect(() => + parseCardMessage(toBase64(buildMessage({ issuedAt: 'not-a-date' }))), + ).toThrow('Invalid Issued At date in card message'); + }); + + it('throws for invalid Expiration Time date', () => { + expect(() => + parseCardMessage( + toBase64(buildMessage({ expirationTime: 'not-a-date' })), + ), + ).toThrow('Invalid Expiration Time date in card message'); + }); + }); + + describe('CardMessageStruct', () => { + const validAddress = '5F9jaU8pWmLJxCk3dHvC1e7d1sWQ9H2kgvA5g4TtK9hF'; + const toBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const validBase64Message = toBase64( + [ + 'example.com wants you to sign in with your Solana account:', + validAddress, + 'Please sign this message.', + 'URI: https://example.com', + 'Version: 1', + 'Chain ID: 1', + 'Nonce: abc123', + 'Issued At: 2024-01-01T00:00:00.000Z', + ].join('\n'), + ); + + it('validates a valid card message', () => { + expect(() => assert(validBase64Message, CardMessageStruct)).not.toThrow(); + expect(is(validBase64Message, CardMessageStruct)).toBe(true); + }); + + it('validates a valid card message with expiration time', () => { + const message = toBase64( + [ + 'example.com wants you to sign in with your Solana account:', + validAddress, + 'Please sign this message.', + 'URI: https://example.com', + 'Version: 1', + 'Chain ID: 1', + 'Nonce: abc123', + 'Issued At: 2024-01-01T00:00:00.000Z', + 'Expiration Time: 2024-01-02T00:00:00.000Z', + ].join('\n'), + ); + expect(() => assert(message, CardMessageStruct)).not.toThrow(); + expect(is(message, CardMessageStruct)).toBe(true); + }); + + it('rejects a message with invalid format', () => { + const message = toBase64('not a siws message'); + expect(() => assert(message, CardMessageStruct)).toThrow( + 'Invalid card message format', + ); + expect(is(message, CardMessageStruct)).toBe(false); + }); + + it.each([123, null, undefined, {}, [], true])( + 'rejects non-string values: "%s"', + (value) => { + expect(is(value, CardMessageStruct)).toBe(false); + }, + ); + }); + + describe('ProofOfOwnershipMessageStruct / parseProofOfOwnershipMessage', () => { + const validSolanaAddress = '5F9jaU8pWmLJxCk3dHvC1e7d1sWQ9H2kgvA5g4TtK9hF'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + it('accepts a well-formed proof-of-ownership message', () => { + const message = `metamask:proof-of-ownership:${nonce}:${validSolanaAddress}`; + expect(() => + assert(message, ProofOfOwnershipMessageStruct), + ).not.toThrow(); + expect(is(message, ProofOfOwnershipMessageStruct)).toBe(true); + expect(parseProofOfOwnershipMessage(message)).toStrictEqual({ + nonce, + address: validSolanaAddress, + }); + }); + + it.each([ + `metamask:proof-of-ownership:${nonce}:${validSolanaAddress}`, + // Nonce with mixed alphanumerics + dashes/underscores (we don't constrain format). + `metamask:proof-of-ownership:abc-DEF_123:${validSolanaAddress}`, + // Nonce containing colons (auth API treats nonces as opaque, addresses + // don't contain ':' so the parser splits on the LAST one). + `metamask:proof-of-ownership:ns:abc:123:${validSolanaAddress}`, + ])('validates valid messages: "%s"', (message) => { + expect(() => + assert(message, ProofOfOwnershipMessageStruct), + ).not.toThrow(); + expect(is(message, ProofOfOwnershipMessageStruct)).toBe(true); + }); + + it('preserves embedded colons in the nonce when parsing', () => { + const colonNonce = 'ns:abc:123'; + const message = `metamask:proof-of-ownership:${colonNonce}:${validSolanaAddress}`; + expect(parseProofOfOwnershipMessage(message)).toStrictEqual({ + nonce: colonNonce, + address: validSolanaAddress, + }); + }); + + it.each([ + `rewards,${validSolanaAddress},123`, + `metamask:proof:${nonce}:${validSolanaAddress}`, + `Metamask:proof-of-ownership:${nonce}:${validSolanaAddress}`, + `${nonce}:${validSolanaAddress}`, + '', + ])('rejects messages without the expected prefix: "%s"', (message) => { + expect(() => assert(message, ProofOfOwnershipMessageStruct)).toThrow( + 'Message must start with "metamask:proof-of-ownership:"', + ); + expect(is(message, ProofOfOwnershipMessageStruct)).toBe(false); + }); + + it('rejects messages missing the address separator', () => { + const message = `metamask:proof-of-ownership:${nonce}`; + expect(() => assert(message, ProofOfOwnershipMessageStruct)).toThrow( + 'Message must follow the format', + ); + }); + + it('rejects messages with an empty nonce', () => { + const message = `metamask:proof-of-ownership::${validSolanaAddress}`; + expect(() => assert(message, ProofOfOwnershipMessageStruct)).toThrow( + 'non-empty nonce', + ); + }); + + it.each([ + `metamask:proof-of-ownership:${nonce}:`, + `metamask:proof-of-ownership:${nonce}:not-a-solana-address`, + `metamask:proof-of-ownership:${nonce}:0x1234567890abcdef1234567890abcdef12345678`, + ])('rejects invalid Solana addresses: "%s"', (message) => { + expect(() => assert(message, ProofOfOwnershipMessageStruct)).toThrow( + 'Invalid Solana address in proof-of-ownership message', + ); + }); + + it.each([123, null, undefined, {}, [], true])( + 'rejects non-string values: "%s"', + (value) => { + expect(is(value, ProofOfOwnershipMessageStruct)).toBe(false); + }, + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts new file mode 100644 index 00000000..94998ece --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -0,0 +1,522 @@ +import { AssetStruct, FeeType } from '@metamask/keyring-api'; +import { literal } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + defaulted, + enums, + is, + object, + optional, + refine, + string, +} from '@metamask/superstruct'; +import { + CaipAssetTypeStruct, + JsonRpcIdStruct, + JsonRpcVersionStruct, + StrictHexStruct, +} from '@metamask/utils'; +import { getBase64Codec, getUtf8Codec, pipe } from '@solana/kit'; + +import { Network } from '../../constants/solana'; +import { SendErrorCodes } from '../../services/send/types'; +import { + ScopeStringStruct, + SolanaSignAndSendTransactionInputStruct, + SolanaSignAndSendTransactionOptionsStruct, +} from '../../services/wallet/structs'; +import { + Base58Struct, + Base64Struct, + PositiveNumberStringStruct, + SolanaAddressStruct, + UuidStruct, +} from '../../validation/structs'; +import { ClientRequestMethod } from './types'; + +/** + * signAndSendTransactionWithoutConfirmation request/response validation. + * TODO: Deprecate this method. + */ +export const SignAndSendTransactionWithoutConfirmationRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal( + ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, + ), + params: SolanaSignAndSendTransactionInputStruct, +}); + +/** + * signAndSendTransaction request/response validation. + */ +export const SignAndSendTransactionRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, + options: optional(SolanaSignAndSendTransactionOptionsStruct), +}); + +export const SignAndSendTransactionRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignAndSendTransaction), + params: SignAndSendTransactionRequestParamsStruct, +}); + +export const SignAndSendTransactionResponseStruct = object({ + transactionId: Base58Struct, +}); + +export type SignAndSendTransactionResponse = Infer< + typeof SignAndSendTransactionResponseStruct +>; + +/** + * onConfirmSend request/response validation. + */ +export const OnConfirmSendRequestParamsStruct = object({ + fromAccountId: UuidStruct, + toAddress: SolanaAddressStruct, + amount: PositiveNumberStringStruct, + assetId: CaipAssetTypeStruct, +}); + +export const OnConfirmSendRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmSend), + params: OnConfirmSendRequestParamsStruct, +}); + +/** + * onAddressInput request/response validation. + */ +export const OnAddressInputRequestParamsStruct = object({ + value: string(), + scope: defaulted(ScopeStringStruct, Network.Mainnet), +}); + +export const OnAddressInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAddressInput), + params: OnAddressInputRequestParamsStruct, +}); + +/** + * onAmountInput request/response validation. + */ +export const OnAmountInputRequestParamsStruct = object({ + value: PositiveNumberStringStruct, + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, +}); + +export const OnAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAmountInput), + params: OnAmountInputRequestParamsStruct, +}); + +/** + * Utility function to decode and parse a rewards message. + * Returns the parsed components or throws an error if invalid. + * + * @param base64Message - The base64-encoded rewards message. + * @returns Object containing the parsed address and timestamp. + * @throws Error if the message format is invalid + */ +export function parseRewardsMessage(base64Message: string): { + address: string; + timestamp: number; +} { + // Decode the message from base64 to utf8 + const decodedMessage = pipe( + base64Message, + getBase64Codec().encode, // From base64 to uint8Array + getUtf8Codec().decode, // From uint8Array to utf8 + ); + + // Check if message starts with 'rewards,' + if (!decodedMessage.startsWith('rewards,')) { + throw new Error('Message must start with "rewards,"'); + } + + // Split the message into parts + const parts = decodedMessage.split(','); + if (parts.length !== 3) { + throw new Error( + 'Message must have exactly 3 parts: rewards,{address},{timestamp}', + ); + } + + const [prefix, addressPart, timestampPart] = parts; + + // Validate prefix (already checked above, but being explicit) + if (prefix !== 'rewards') { + throw new Error('Message must start with "rewards"'); + } + + // Validate Solana address + if (!is(addressPart, SolanaAddressStruct)) { + throw new Error('Invalid Solana address'); + } + + // Validate timestamp + if (!is(timestampPart, PositiveNumberStringStruct)) { + throw new Error('Invalid timestamp format'); + } + + // Ensure timestamp is an integer (no decimals) + if (timestampPart.includes('.')) { + throw new Error('Invalid timestamp'); + } + + const timestamp = parseInt(timestampPart, 10); + if (timestamp <= 0) { + throw new Error('Invalid timestamp'); + } + + return { + address: addressPart, + timestamp, + }; +} + +/** + * Validates that a base64-encoded message follows the rewards format: + * 'rewards,{address},{timestamp}' + * - Must be valid base64 + * - When decoded, must start with 'rewards,' + * - Must contain a valid Solana address + * - Must contain a valid timestamp + */ +export const RewardsMessageStruct = refine( + Base64Struct, + 'RewardsMessage', + (value: string) => { + try { + parseRewardsMessage(value); + return true; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid rewards message'; + } + }, +); + +export const SignRewardsMessageRequestParamsStruct = object({ + accountId: UuidStruct, + message: RewardsMessageStruct, +}); + +export const SignRewardsMessageRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignRewardsMessage), + params: SignRewardsMessageRequestParamsStruct, +}); + +/** + * Utility function to decode and parse a card message. + * The card message follows a SIWS (Sign-In with Solana) style format: + * `{domain} wants you to sign in with your Solana account: {address} {statement} URI: {uri} Version: {version} Chain ID: {chainId} Nonce: {nonce} Issued At: {issuedAt} [Expiration Time: {expirationTime}]` + * + * @param base64Message - The base64-encoded card message. + * @returns Object containing the parsed components. + * @throws Error if the message format is invalid + */ +export function parseCardMessage(base64Message: string): { + domain: string; + address: string; + statement: string; + uri: string; + version: string; + chainId: string; + nonce: string; + issuedAt: string; + expirationTime?: string; +} { + // Decode the message from base64 to utf8 + const decodedMessage = pipe( + base64Message, + getBase64Codec().encode, // From base64 to uint8Array + getUtf8Codec().decode, // From uint8Array to utf8 + ); + + // Normalize whitespace (newline-separated and space-separated formats both supported) + // Note: Nonce accepts alphanumeric characters to support various partner formats + // Note: Expiration Time is optional + const normalizedMessage = decodedMessage.replaceAll(/\s+/gu, ' ').trim(); + const regex = + /^(\S+) wants you to sign in with your Solana account: (\S{32,44}) (.+?) URI: (\S+) Version: (\d+) Chain ID: (\d+) Nonce: (\w+) Issued At: (\S+)(?: Expiration Time: (\S+))?$/u; + + const match = regex.exec(normalizedMessage); + + if (!match) { + throw new Error( + 'Invalid card message format. Expected format: "{domain} wants you to sign in with your Solana account: {address} {statement} URI: {uri} Version: {version} Chain ID: {chainId} Nonce: {nonce} Issued At: {issuedAt} [Expiration Time: {expirationTime}]"', + ); + } + + const [ + , + domain, + addressPart, + , + uri, + version, + chainId, + nonce, + issuedAt, + expirationTime, + ] = match as unknown as [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string | undefined, + ]; + + // Re-extract the statement from the original decoded message to preserve internal whitespace + const addressIdx = decodedMessage.indexOf(addressPart); + const uriMarkerIdx = decodedMessage.indexOf('URI:'); + const statement = decodedMessage + .slice(addressIdx + addressPart.length, uriMarkerIdx) + .trim(); + + // Validate domain + if (!domain || domain.trim() === '') { + throw new Error('Invalid domain in card message'); + } + + // Validate Solana address + if (!is(addressPart, SolanaAddressStruct)) { + throw new Error('Invalid Solana address in card message'); + } + + // Validate URI format + try { + const parsedUrl = new URL(uri); + if (!parsedUrl.protocol) { + throw new Error('Invalid URI'); + } + } catch { + throw new Error('Invalid URI in card message'); + } + + // Validate issuedAt is a valid ISO 8601 date + const issuedAtDate = new Date(issuedAt); + if (isNaN(issuedAtDate.getTime())) { + throw new Error('Invalid Issued At date in card message'); + } + + // Validate expirationTime is a valid ISO 8601 date (if present) + if (expirationTime !== undefined) { + const expirationTimeDate = new Date(expirationTime); + if (isNaN(expirationTimeDate.getTime())) { + throw new Error('Invalid Expiration Time date in card message'); + } + } + + return { + domain, + address: addressPart, + statement, + uri, + version, + chainId, + nonce, + issuedAt, + ...(expirationTime !== undefined && { expirationTime }), + }; +} + +/** + * Validates that a base64-encoded message follows the card message format (SIWS style). + */ +export const CardMessageStruct = refine( + Base64Struct, + 'CardMessage', + (value: string) => { + try { + parseCardMessage(value); + return true; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid card message'; + } + }, +); + +/** + * signCardMessage request/response validation. + */ +export const SignCardMessageRequestParamsStruct = object({ + accountId: UuidStruct, + message: CardMessageStruct, +}); + +export const SignCardMessageRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignCardMessage), + params: SignCardMessageRequestParamsStruct, +}); + +/** + * approveCardAmount request/response validation. + * This method creates a token approval (delegate) transaction on chain. + */ +export const ApproveCardAmountRequestParamsStruct = object({ + accountId: UuidStruct, + amount: PositiveNumberStringStruct, + mint: SolanaAddressStruct, + delegate: SolanaAddressStruct, + scope: ScopeStringStruct, +}); + +export const ApproveCardAmountRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ApproveCardAmount), + params: ApproveCardAmountRequestParamsStruct, +}); + +export const ApproveCardAmountResponseStruct = object({ + signature: Base58Struct, +}); + +export const ValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendErrorCodes)), + }), + ), +}); + +export type ValidationResponse = Infer; + +/** + * computeFee request/response validation. + */ +export const ComputeFeeRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, +}); + +export const ComputeFeeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ComputeFee), + params: ComputeFeeRequestParamsStruct, +}); + +export const ComputeFeeResponseStruct = array( + object({ + type: enums(Object.values(FeeType)), + asset: AssetStruct, + }), +); + +export type ComputeFeeResponse = Infer; + +export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; + +/** + * Utility function to parse a proof-of-ownership message, of format `'metamask:proof-of-ownership:{nonce}:{address}'`. + * Returns the parsed components or throws an error if invalid. + * + * @param message - The plaintext proof-of-ownership message. + * @returns Object containing the parsed nonce and address. + * @throws Error if the message format is invalid + */ +export function parseProofOfOwnershipMessage(message: string): { + nonce: string; + address: string; +} { + if (!message.startsWith(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX)) { + throw new Error( + `Message must start with "${PROOF_OF_OWNERSHIP_MESSAGE_PREFIX}"`, + ); + } + + const remainder = message.slice(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX.length); + const separatorIdx = remainder.lastIndexOf(':'); + if (separatorIdx === -1) { + throw new Error( + 'Message must follow the format "metamask:proof-of-ownership:{nonce}:{address}"', + ); + } + + const nonce = remainder.slice(0, separatorIdx); + const address = remainder.slice(separatorIdx + 1); + + if (nonce === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty nonce', + ); + } + + if (!is(address, SolanaAddressStruct)) { + throw new Error('Invalid Solana address in proof-of-ownership message'); + } + + return { nonce, address }; +} + +/** + * Validates that a plaintext message follows the proof-of-ownership format: + * 'metamask:proof-of-ownership:{nonce}:{address}' + */ +export const ProofOfOwnershipMessageStruct = refine( + string(), + 'ProofOfOwnershipMessage', + (value: string) => { + try { + parseProofOfOwnershipMessage(value); + return true; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid proof-of-ownership message'; + } + }, +); + +/** + * signProofOfOwnership request/response validation. + */ +export const SignProofOfOwnershipRequestParamsStruct = object({ + accountId: UuidStruct, + message: ProofOfOwnershipMessageStruct, +}); + +export const SignProofOfOwnershipRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignProofOfOwnership), + params: SignProofOfOwnershipRequestParamsStruct, +}); + +export const SignProofOfOwnershipResponseStruct = object({ + /** + * 0x-prefixed hex encoding of the 64-byte ed25519 signature. + * The identity auth API mandates this encoding for `solana` proofs of + * ownership. + */ + signature: StrictHexStruct, +}); + +export type SignProofOfOwnershipResponse = Infer< + typeof SignProofOfOwnershipResponseStruct +>; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts new file mode 100644 index 00000000..58ae0240 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts @@ -0,0 +1,14 @@ +export enum ScheduleBackgroundEventMethod { + /** Triggered when a transaction is shown in confirmation UI */ + OnTransactionAdded = 'onTransactionAdded', + /** Triggered when the user confirms a transaction in the confirmation UI */ + OnTransactionApproved = 'onTransactionApproved', + /** Triggered when a transaction is rejected */ + OnTransactionRejected = 'onTransactionRejected', + /** Use it to schedule a background event to refresh the send form */ + RefreshSend = 'refreshSend', + /** Use it to schedule a background event to refresh the confirmation estimation */ + RefreshConfirmationEstimation = 'refreshConfirmationEstimation', + /** Use it to schedule a background event to close WebSocket connections */ + CloseWebSocketConnections = 'closeWebSocketConnections', +} diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/closeWebSocketConnections.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/closeWebSocketConnections.ts new file mode 100644 index 00000000..53759ed5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/closeWebSocketConnections.ts @@ -0,0 +1,10 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { webSocketConnectionService } from '../../../../snapContext'; +import logger from '../../../utils/logger'; + +export const closeWebSocketConnections: OnCronjobHandler = async () => { + logger.info('Closing all WebSocket connections via background event'); + + await webSocketConnectionService.closeAllConnections(); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/index.ts new file mode 100644 index 00000000..9b999e40 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/index.ts @@ -0,0 +1,23 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { closeWebSocketConnections } from './closeWebSocketConnections'; +import { onTransactionAdded } from './onTransactionAdded'; +import { onTransactionApproved } from './onTransactionApproved'; +import { onTransactionRejected } from './onTransactionRejected'; +import { refreshConfirmationEstimation } from './refreshConfirmationEstimation'; +import { refreshSend } from './refreshSend'; +import { ScheduleBackgroundEventMethod } from './ScheduleBackgroundEventMethod'; + +export const handlers: Record = + { + [ScheduleBackgroundEventMethod.OnTransactionAdded]: onTransactionAdded, + [ScheduleBackgroundEventMethod.OnTransactionApproved]: + onTransactionApproved, + [ScheduleBackgroundEventMethod.OnTransactionRejected]: + onTransactionRejected, + [ScheduleBackgroundEventMethod.RefreshSend]: refreshSend, + [ScheduleBackgroundEventMethod.RefreshConfirmationEstimation]: + refreshConfirmationEstimation, + [ScheduleBackgroundEventMethod.CloseWebSocketConnections]: + closeWebSocketConnections, + }; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts new file mode 100644 index 00000000..bf38a0f4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionAdded.ts @@ -0,0 +1,45 @@ +import { InternalError } from '@metamask/snaps-sdk'; +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { analyticsService, keyring } from '../../../../snapContext'; +import logger from '../../../utils/logger'; +import { NetworkStruct, UuidStruct } from '../../../validation/structs'; +import { ScheduleBackgroundEventMethod } from './ScheduleBackgroundEventMethod'; + +export const OnTransactionAddedRequestStruct = object({ + id: string(), + jsonrpc: literal('2.0'), + method: literal(ScheduleBackgroundEventMethod.OnTransactionAdded), + params: object({ + accountId: UuidStruct, + metadata: object({ + scope: NetworkStruct, + origin: string(), + }), + }), +}); + +/** + * Handles side effects that need to happen when a transaction is shown in confirmation UI. + * + * @param args - The arguments object. + * @param args.request - The request object containing transaction details. + * @returns A promise that resolves when the side effects are complete. + */ +export const onTransactionAdded: OnCronjobHandler = async ({ request }) => { + try { + logger.info('[onTransactionAdded] Cronjob triggered', request); + + assert(request, OnTransactionAddedRequestStruct); + + const { accountId, metadata } = request.params; + + const account = await keyring.getAccountOrThrow(accountId); + + await analyticsService.trackEventTransactionAdded(account, metadata); + } catch (error) { + logger.error(error); + throw new InternalError(error as string) as Error; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts new file mode 100644 index 00000000..a50b4c02 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionApproved.ts @@ -0,0 +1,45 @@ +import { InternalError } from '@metamask/snaps-sdk'; +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { analyticsService, keyring } from '../../../../snapContext'; +import logger from '../../../utils/logger'; +import { NetworkStruct, UuidStruct } from '../../../validation/structs'; +import { ScheduleBackgroundEventMethod } from './ScheduleBackgroundEventMethod'; + +export const OnTransactionApprovedRequestStruct = object({ + id: string(), + jsonrpc: literal('2.0'), + method: literal(ScheduleBackgroundEventMethod.OnTransactionApproved), + params: object({ + accountId: UuidStruct, + metadata: object({ + scope: NetworkStruct, + origin: string(), + }), + }), +}); + +/** + * Handles side effects that need to happen when the user confirms a transaction in the confirmation UI. + * + * @param args - The arguments object. + * @param args.request - The request object containing transaction details. + * @returns A promise that resolves when the side effects are complete. + */ +export const onTransactionApproved: OnCronjobHandler = async ({ request }) => { + try { + logger.info('[onTransactionApproved] Cronjob triggered', request); + + assert(request, OnTransactionApprovedRequestStruct); + + const { accountId, metadata } = request.params; + + const account = await keyring.getAccountOrThrow(accountId); + + await analyticsService.trackEventTransactionApproved(account, metadata); + } catch (error) { + logger.error(error); + throw new InternalError(error as string) as Error; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts new file mode 100644 index 00000000..c9242bc1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/onTransactionRejected.ts @@ -0,0 +1,45 @@ +import { InternalError } from '@metamask/snaps-sdk'; +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; +import { assert, literal, object, string } from '@metamask/superstruct'; + +import { analyticsService, keyring } from '../../../../snapContext'; +import logger from '../../../utils/logger'; +import { NetworkStruct, UuidStruct } from '../../../validation/structs'; +import { ScheduleBackgroundEventMethod } from './ScheduleBackgroundEventMethod'; + +export const OnTransactionRejectedRequestStruct = object({ + id: string(), + jsonrpc: literal('2.0'), + method: literal(ScheduleBackgroundEventMethod.OnTransactionRejected), + params: object({ + accountId: UuidStruct, + metadata: object({ + scope: NetworkStruct, + origin: string(), + }), + }), +}); + +/** + * Handles side effects that need to happen when user rejects a transaction (= does not confirm it). + * + * @param args - The arguments object. + * @param args.request - The request object containing transaction details. + * @returns A promise that resolves when the side effects are complete. + */ +export const onTransactionRejected: OnCronjobHandler = async ({ request }) => { + try { + logger.info('[onTransactionRejected] Cronjob triggered', request); + + assert(request, OnTransactionRejectedRequestStruct); + + const { accountId, metadata } = request.params; + + const account = await keyring.getAccountOrThrow(accountId); + + await analyticsService.trackEventTransactionRejected(account, metadata); + } catch (error) { + logger.error(error); + throw new InternalError(error as string) as Error; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.test.tsx b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.test.tsx new file mode 100644 index 00000000..001dbefb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.test.tsx @@ -0,0 +1,84 @@ +import { SolMethod } from '@metamask/keyring-api'; + +import { transactionScanService, state } from '../../../../snapContext'; +import { Network } from '../../../constants/solana'; +import { serialize } from '../../../serialization/serialize'; +import { trackError } from '../../../utils/errors'; +import { getInterfaceContext, updateInterface } from '../../../utils/interface'; +import { refreshConfirmationEstimation } from './refreshConfirmationEstimation'; + +jest.mock('../../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +jest.mock('../../../serialization/serialize', () => ({ + serialize: jest.fn((value) => value), +})); + +jest.mock('../../../utils/interface', () => ({ + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME: 'confirmation-interface', + getInterfaceContext: jest.fn(), + updateInterface: jest.fn(), +})); + +jest.mock( + '../../../../features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest', + () => ({ + ConfirmTransactionRequest: () => null, + }), +); + +jest.mock('../../../../snapContext', () => ({ + state: { + getKey: jest.fn(), + }, + transactionScanService: { + scanTransaction: jest.fn(), + }, +})); + +const setupTest = () => { + const interfaceContext = { + account: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }, + transaction: 'mock-transaction', + scope: Network.Mainnet, + method: SolMethod.SignAndSendTransaction, + origin: 'https://metamask.io', + preferences: { + simulateOnChainActions: true, + }, + scanFetchStatus: 'fetched', + }; + + (state.getKey as jest.Mock).mockResolvedValue({ + 'confirmation-interface': 'interface-id', + }); + (getInterfaceContext as jest.Mock).mockResolvedValue(interfaceContext); + (updateInterface as jest.Mock).mockResolvedValue(undefined); + (serialize as jest.Mock).mockImplementation((value) => value); +}; + +describe('refreshConfirmationEstimation', () => { + it('tracks refresh failures and restores the fetched state', async () => { + setupTest(); + + const error = new Error('Scan failed'); + + (transactionScanService.scanTransaction as jest.Mock).mockRejectedValue( + error, + ); + + await refreshConfirmationEstimation({ request: {} as any }); + + expect(trackError).toHaveBeenCalledWith(error); + expect(updateInterface).toHaveBeenLastCalledWith( + 'interface-id', + null, + expect.objectContaining({ + scanFetchStatus: 'fetched', + }), + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx new file mode 100644 index 00000000..dec3596c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx @@ -0,0 +1,160 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { ConfirmTransactionRequest } from '../../../../features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from '../../../../features/confirmation/views/ConfirmTransactionRequest/types'; +import { state, transactionScanService } from '../../../../snapContext'; +import { serialize } from '../../../serialization/serialize'; +import type { UnencryptedStateValue } from '../../../services/state/State'; +import { trackError } from '../../../utils/errors'; +import { + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME, + getInterfaceContext, + updateInterface, +} from '../../../utils/interface'; +import baseLogger, { createPrefixedLogger } from '../../../utils/logger'; + +export const refreshConfirmationEstimation: OnCronjobHandler = async () => { + const logger = createPrefixedLogger( + baseLogger, + '[refreshConfirmationEstimation]', + ); + + logger.info(`Background event triggered`); + + const mapInterfaceNameToId = + (await state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME]; + + // Don't do anything if the confirmation interface is not open + if (!confirmationInterfaceId) { + logger.info(`No interface context found`); + return; + } + + // Check if context exists in case the UI was closed before the background event ran + const interfaceContext = + await getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + logger.info(`Interface context no longer exists, skipping refresh`); + return; + } + + // Update the interface context with the new rates. + try { + if ( + !interfaceContext.account?.address || + !interfaceContext.transaction || + !interfaceContext.scope || + !interfaceContext.method + ) { + logger.info(`Context is missing required fields`); + return; + } + + // Skip transaction simulation if the preference is disabled + if (!interfaceContext.preferences?.simulateOnChainActions) { + logger.info(`Transaction simulation is disabled in preferences`); + return; + } + + const fetchingConfirmationContext = { + ...interfaceContext, + scanFetchStatus: 'fetching', + } as ConfirmTransactionRequestContext; + + await updateInterface( + confirmationInterfaceId, + , + fetchingConfirmationContext, + ); + + const [scan, updatedInterfaceContextFinal] = await Promise.all([ + transactionScanService.scanTransaction({ + method: interfaceContext.method, + accountAddress: interfaceContext.account.address, + transaction: interfaceContext.transaction, + scope: interfaceContext.scope, + origin: interfaceContext.origin, + account: interfaceContext.account, + }), + getInterfaceContext( + confirmationInterfaceId, + ), + ]); + + // Check if context exists in case the UI was closed while scanning + if (!updatedInterfaceContextFinal) { + logger.info( + `Interface context no longer exists after scan, skipping update`, + ); + return; + } + + // Update the current context with the new rates + const updatedInterfaceContext = { + ...updatedInterfaceContextFinal, + scanFetchStatus: 'fetched' as const, + scan, + }; + logger.info(`New scan fetched`); + + await updateInterface( + confirmationInterfaceId, + , + updatedInterfaceContext, + ); + + logger.info(`Background event suceeded`); + + // Schedule the next run + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT20S', + request: { method: 'refreshConfirmationEstimation' }, + }, + }); + } catch (error) { + await trackError(error); + + // Check if context exists in case the UI was closed, nothing to rollback + const fetchedInterfaceContext = + await getInterfaceContext( + confirmationInterfaceId, + ); + + if (!fetchedInterfaceContext) { + logger.info(`Interface context no longer exists, skipping rollback`); + return; + } + + const fetchingConfirmationContext = { + ...fetchedInterfaceContext, + scanFetchStatus: 'fetched', + } as ConfirmTransactionRequestContext; + + await updateInterface( + confirmationInterfaceId, + , + fetchingConfirmationContext, + ); + + logger.warn( + { error }, + `Could not update the interface. But rolled back status to fetched.`, + ); + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx new file mode 100644 index 00000000..a0eb261f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx @@ -0,0 +1,86 @@ +import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import { KnownCaip19Id } from '../../../constants/solana'; +import { trackError } from '../../../utils/errors'; +import { + getInterfaceContext, + getPreferences, + updateInterface, +} from '../../../utils/interface'; +import { refreshSend } from './refreshSend'; + +jest.mock('../../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +jest.mock('../../../utils/interface', () => ({ + getInterfaceContext: jest.fn(), + getPreferences: jest.fn(), + SEND_FORM_INTERFACE_NAME: 'send-form', + updateInterface: jest.fn(), +})); + +jest.mock('../../../../features/send/render', () => ({ + DEFAULT_SEND_CONTEXT: { + preferences: { + locale: 'en', + currency: 'usd', + }, + }, +})); + +jest.mock('../../../../features/send/Send', () => ({ + Send: () => null, +})); + +jest.mock('../../../../snapContext', () => ({ + assetsService: { + getAll: jest.fn(), + }, + priceApiClient: { + getMultipleSpotPrices: jest.fn(), + }, + state: { + getKey: jest.fn(), + setKey: jest.fn(), + }, +})); + +const setupTest = () => { + (globalThis as any).snap = { + request: jest.fn(), + }; + + (assetsService.getAll as jest.Mock).mockResolvedValue([ + { + assetType: KnownCaip19Id.SolMainnet, + }, + ] as any); + (state.getKey as jest.Mock).mockResolvedValue({ + 'send-form': 'interface-id', + }); + (getPreferences as jest.Mock).mockResolvedValue({ + locale: 'en', + currency: 'usd', + }); + (getInterfaceContext as jest.Mock).mockResolvedValue({ + tokenPrices: {}, + }); + (updateInterface as jest.Mock).mockResolvedValue(undefined); +}; + +describe('refreshSend', () => { + it('tracks background refresh errors', async () => { + setupTest(); + + const error = new Error('Price API failed'); + + (priceApiClient.getMultipleSpotPrices as jest.Mock).mockRejectedValue( + error, + ); + + await refreshSend({ request: {} as any }); + + expect(trackError).toHaveBeenCalledWith(error); + expect(updateInterface).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx new file mode 100644 index 00000000..68e38712 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx @@ -0,0 +1,96 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; +import { Send } from '../../../../features/send/Send'; +import type { SendContext } from '../../../../features/send/types'; +import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import type { UnencryptedStateValue } from '../../../services/state/State'; +import { trackError } from '../../../utils/errors'; +import { + getInterfaceContext, + getPreferences, + SEND_FORM_INTERFACE_NAME, + updateInterface, +} from '../../../utils/interface'; +import baseLogger, { createPrefixedLogger } from '../../../utils/logger'; + +export const refreshSend: OnCronjobHandler = async () => { + const logger = createPrefixedLogger(baseLogger, '[refreshSend]'); + + logger.info(`Background event triggered`); + + const [assets, mapInterfaceNameToId, preferences] = await Promise.all([ + assetsService.getAll(), + state.getKey( + 'mapInterfaceNameToId', + ), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assetTypes = assets.flatMap((asset) => asset.assetType); + + const sendFormInterfaceId = mapInterfaceNameToId?.[SEND_FORM_INTERFACE_NAME]; + + // Don't do anything if the send form interface is not open + if (!sendFormInterfaceId) { + logger.info(`No send form interface found`); + return; + } + + // Check if context exists in case the UI was closed before the background event ran + const interfaceContext = + await getInterfaceContext(sendFormInterfaceId); + + if (!interfaceContext) { + logger.info(`Interface context no longer exists, skipping refresh`); + return; + } + + try { + // First, fetch the token prices + const tokenPrices = await priceApiClient.getMultipleSpotPrices( + assetTypes, + preferences.currency, + ); + + // Save them in the state + await state.setKey('tokenPrices', tokenPrices); + + // Check if context exists in case the UI was closed while fetching prices + const latestInterfaceContext = + await getInterfaceContext(sendFormInterfaceId); + + if (!latestInterfaceContext) { + logger.info( + `Interface context no longer exists after fetching prices, skipping update`, + ); + return; + } + + // Update the current context with the new rates + const updatedInterfaceContext = { + ...latestInterfaceContext, + tokenPrices: { + ...latestInterfaceContext.tokenPrices, + ...tokenPrices, + }, + }; + + await updateInterface( + sendFormInterfaceId, + , + updatedInterfaceContext, + ); + + logger.info(`✅ Background event suceeded`); + + // Schedule the next run + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { duration: 'PT30S', request: { method: 'refreshSend' } }, + }); + } catch (error) { + await trackError(error); + logger.warn({ error }, `Could not refresh send interface`); + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts new file mode 100644 index 00000000..ccaa2122 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts @@ -0,0 +1 @@ +export enum CronjobMethod {} diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/index.ts new file mode 100644 index 00000000..10cdf86e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/cronjobs/index.ts @@ -0,0 +1,5 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import type { CronjobMethod } from './CronjobMethod'; + +export const handlers: Record = {}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/index.ts new file mode 100644 index 00000000..08142761 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onCronjob/index.ts @@ -0,0 +1,14 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { handlers as backgroundEventHandlers } from './backgroundEvents'; +import type { ScheduleBackgroundEventMethod } from './backgroundEvents/ScheduleBackgroundEventMethod'; +import { handlers as cronjobHandlers } from './cronjobs'; +import type { CronjobMethod } from './cronjobs/CronjobMethod'; + +export const handlers: Record< + CronjobMethod | ScheduleBackgroundEventMethod, + OnCronjobHandler +> = { + ...cronjobHandlers, + ...backgroundEventHandlers, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts new file mode 100644 index 00000000..5c27fe3e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -0,0 +1,793 @@ +/* eslint-disable no-restricted-globals */ + +import type { KeyringRequest } from '@metamask/keyring-api'; +import { AccountCreationType, SolMethod } from '@metamask/keyring-api'; +import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; +import type { CaipAssetType, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { signature } from '@solana/kit'; +import bs58 from 'bs58'; + +import type { AssetEntity } from '../../../entities'; +import { asStrictKeyringAccount } from '../../../entities'; +import { KnownCaip19Id, Network } from '../../constants/solana'; +import type { + AssetsService, + KeyringAccountMonitor, + TransactionsService, +} from '../../services'; +import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; +import { InMemoryState } from '../../services/state/InMemoryState'; +import type { IStateManager } from '../../services/state/IStateManager'; +import { DEFAULT_UNENCRYPTED_STATE } from '../../services/state/State'; +import type { UnencryptedStateValue } from '../../services/state/State'; +import { MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST } from '../../services/wallet/mocks'; +import type { WalletService } from '../../services/wallet/WalletService'; +import { + MOCK_ASSET_ENTITIES, + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, +} from '../../test/mocks/asset-entities'; +import { + MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + MOCK_SEED_PHRASE_ENTROPY_SOURCE, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, + MOCK_SOLANA_KEYRING_ACCOUNTS, + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import { getBip32EntropyMock } from '../../test/mocks/utils/getBip32Entropy'; +import { trackError } from '../../utils/errors'; +import { getBip32Entropy } from '../../utils/getBip32Entropy'; +import logger from '../../utils/logger'; +import { SolanaKeyring } from './Keyring'; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + ...jest.requireActual('@metamask/keyring-snap-sdk'), + emitSnapKeyringEvent: jest.fn().mockResolvedValue(null), +})); + +jest.mock('../../utils/getBip32Entropy', () => ({ + getBip32Entropy: getBip32EntropyMock, +})); + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +const NON_EXISTENT_ACCOUNT_ID = '123e4567-e89b-12d3-a456-426614174009'; + +(globalThis as any).snap = { + request: jest.fn().mockImplementation(async ({ method }) => { + if (method === 'snap_listEntropySources') { + return Promise.resolve([ + { id: MOCK_SEED_PHRASE_ENTROPY_SOURCE, primary: true }, + { id: MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, primary: false }, + ]); + } + + return Promise.resolve({}); + }), +}; + +describe('SolanaKeyring', () => { + let keyring: SolanaKeyring; + let mockState: IStateManager; + let mockWalletService: WalletService; + let mockAssetsService: AssetsService; + let mockConfirmationHandler: ConfirmationHandler; + let mockTransactionsService: jest.Mocked; + let mockKeyringAccountMonitor: KeyringAccountMonitor; + + beforeEach(() => { + jest.clearAllMocks(); + + // To simplify the mocking of individual tests, we initialize the state in happy path with all mock accounts + mockState = new InMemoryState({ + ...DEFAULT_UNENCRYPTED_STATE, + keyringAccounts: MOCK_SOLANA_KEYRING_ACCOUNTS.reduce( + (acc, account) => ({ + ...acc, + [account.id]: account, + }), + {}, + ), + }); + + mockAssetsService = { + fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), + saveMany: jest.fn(), + findByAccount: jest.fn(), + getNativeAssetTypes: jest + .fn() + .mockReturnValue([KnownCaip19Id.SolMainnet]), + } as unknown as AssetsService; + + mockWalletService = { + resolveAccountAddress: jest.fn(), + signIn: jest.fn(), + signTransaction: jest.fn(), + signMessage: jest.fn(), + signAndSendTransaction: jest.fn(), + } as unknown as WalletService; + + mockConfirmationHandler = { + handleKeyringRequest: jest.fn(), + } as unknown as ConfirmationHandler; + + mockTransactionsService = { + fetchLatestSignatures: jest.fn(), + } as unknown as jest.Mocked; + + mockKeyringAccountMonitor = { + setMonitoredAccounts: jest.fn(), + } as unknown as KeyringAccountMonitor; + + keyring = new SolanaKeyring({ + state: mockState, + logger, + transactionsService: mockTransactionsService, + assetsService: mockAssetsService, + walletService: mockWalletService, + confirmationHandler: mockConfirmationHandler, + keyringAccountMonitor: mockKeyringAccountMonitor, + }); + }); + + describe('getAccountAssets', () => { + it('calls the assets service', async () => { + jest + .spyOn(mockAssetsService, 'findByAccount') + .mockResolvedValue(MOCK_ASSET_ENTITIES); + + const result = await keyring.getAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(result).toStrictEqual([ + MOCK_ASSET_ENTITY_0.assetType, + MOCK_ASSET_ENTITY_1.assetType, + MOCK_ASSET_ENTITY_2.assetType, + ]); + }); + + it('removes token assets with zero balance', async () => { + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance + ]); + + const result = await keyring.getAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(result).toStrictEqual([MOCK_ASSET_ENTITY_1.assetType]); + }); + + it('keeps the native asset even if it has zero balance', async () => { + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance + { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance + ]); + + const result = await keyring.getAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(result).toStrictEqual([MOCK_ASSET_ENTITY_0.assetType]); + }); + + it('throws and error if the account provided is not a uuid', async () => { + await expect(keyring.getAccountAssets('non-existent-id')).rejects.toThrow( + /Expected a string matching/u, + ); + }); + + it('throws an error if account is not found', async () => { + await expect( + keyring.getAccountAssets(NON_EXISTENT_ACCOUNT_ID), + ).rejects.toThrow(`Account "${NON_EXISTENT_ACCOUNT_ID}" not found`); + }); + }); + + describe('getAccount', () => { + it('gets account by id', async () => { + const account = await keyring.getAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + ); + expect(account).toStrictEqual( + asStrictKeyringAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1), + ); + }); + + it('throws and error if the account provided is not a uuid', async () => { + await expect(keyring.getAccount('non-existent-id')).rejects.toThrow( + /Expected a string matching/u, + ); + }); + + it('throws if account is not found', async () => { + await expect(keyring.getAccount(NON_EXISTENT_ACCOUNT_ID)).rejects.toThrow( + `Account "${NON_EXISTENT_ACCOUNT_ID}" not found`, + ); + }); + + it('throws an error if state fails to be retrieved', async () => { + jest + .spyOn(mockState, 'getKey') + .mockRejectedValueOnce(new Error('State error')); + + await expect( + keyring.getAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id), + ).rejects.toThrow('State error'); + }); + + it('wraps state errors in a single SnapError (no double-wrap)', async () => { + jest + .spyOn(mockState, 'getKey') + .mockRejectedValueOnce(new Error('State error')); + // SnapError's constructor copies the wrapped error's message verbatim, + // so a single SnapError and a SnapError-of-SnapError look identical at + // the message level. We instead detect double-wrapping via the log + // pattern: each wrap site logs 'Error getting account'. + const errorLogSpy = jest.spyOn(logger, 'error'); + + const caught = await keyring + .getAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id) + .catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(SnapError); + // The prefixed logger calls the underlying logger as + // (prefix, errorContext, message), so the human-readable message + // sits at index 2. + const errorLogCalls = errorLogSpy.mock.calls.filter( + (call) => call[2] === 'Error getting account', + ); + expect(errorLogCalls).toHaveLength(1); + }); + }); + + describe('getAccounts', () => { + it('returns all accounts from the state', async () => { + const accounts = await keyring.getAccounts(); + expect(accounts).toHaveLength(MOCK_SOLANA_KEYRING_ACCOUNTS.length); + expect(accounts).toContainEqual( + asStrictKeyringAccount(MOCK_SOLANA_KEYRING_ACCOUNT_0), + ); + expect(accounts).toContainEqual( + asStrictKeyringAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1), + ); + }); + + it('returns an empty array if no accounts are found', async () => { + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce({}); + + const accounts = await keyring.getAccounts(); + expect(accounts).toStrictEqual([]); + }); + + it('throws a SnapError if state fails to be retrieved', async () => { + jest + .spyOn(mockState, 'getKey') + .mockRejectedValueOnce(new Error('State error')); + + const caught = await keyring + .getAccounts() + .catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(SnapError); + expect((caught as SnapError).message).toBe('Error listing accounts'); + }); + }); + + describe('getAccountOrThrow', () => { + it('throws an error if account is not found', async () => { + await expect( + keyring.getAccountOrThrow(NON_EXISTENT_ACCOUNT_ID), + ).rejects.toThrow(`Account "${NON_EXISTENT_ACCOUNT_ID}" not found`); + }); + }); + + describe('deleteAccount', () => { + it('deletes an account', async () => { + const accountBeforeDeletion = await keyring.getAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + ); + expect(accountBeforeDeletion).toBeDefined(); + + await keyring.deleteAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id); + + await expect( + keyring.getAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id), + ).rejects.toThrow( + `Account "${MOCK_SOLANA_KEYRING_ACCOUNT_1.id}" not found`, + ); + }); + + it('throws an error if account provided is not a uuid', async () => { + await expect(keyring.deleteAccount('non-existent-id')).rejects.toThrow( + /Expected a string matching/u, + ); + }); + }); + + describe('getAccountBalances', () => { + it('rejects invalid params', async () => { + await expect( + keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ + KnownCaip19Id.SolMainnet, + 'Bob' as unknown as CaipAssetType, + ]), + ).rejects.toThrow( + 'At path: assets.1 -- Expected a value of type `CaipAssetType`, but received: `"Bob"`', + ); + }); + + it('throws an error if account is not found', async () => { + await expect( + keyring.getAccountBalances(NON_EXISTENT_ACCOUNT_ID, [ + KnownCaip19Id.SolMainnet, + ]), + ).rejects.toThrow(`Account "${NON_EXISTENT_ACCOUNT_ID}" not found`); + }); + + it('rejects invalid responses', async () => { + const invalidAsset = { + ...MOCK_ASSET_ENTITY_0, + symbol: 4, + } as unknown as AssetEntity; + + jest + .spyOn(mockAssetsService, 'findByAccount') + .mockResolvedValue([invalidAsset]); + + await expect( + keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ + KnownCaip19Id.SolMainnet, + ]), + ).rejects.toThrow('Invalid Response'); + }); + + it('removes token assets with zero balance', async () => { + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance + ]); + + const result = await keyring.getAccountBalances( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_1.assetType, MOCK_ASSET_ENTITY_2.assetType], + ); + + expect(result).toStrictEqual({ + [MOCK_ASSET_ENTITY_1.assetType]: { + amount: MOCK_ASSET_ENTITY_1.uiAmount, + unit: MOCK_ASSET_ENTITY_1.symbol, + }, + }); + }); + + it('keeps the native asset even if it has zero balance', async () => { + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance + { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance + ]); + + const result = await keyring.getAccountBalances( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(result).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: { + amount: MOCK_ASSET_ENTITY_0.uiAmount, + unit: MOCK_ASSET_ENTITY_0.symbol, + }, + }); + }); + }); + + describe('resolveAccountAddress', () => { + it('returns resolved address when wallet standard service resolves successfully', async () => { + const mockScope = Network.Localnet; + const mockRequest = { + id: 1, + jsonrpc: '2.0', + ...MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, + } as unknown as JsonRpcRequest; + const mockResolvedAddress = `${mockScope}:resolved-address`; + + jest + .spyOn(mockWalletService, 'resolveAccountAddress') + .mockResolvedValue(mockResolvedAddress); + + const result = await keyring.resolveAccountAddress( + mockScope, + mockRequest, + ); + + expect(result).toStrictEqual({ address: mockResolvedAddress }); + expect(mockWalletService.resolveAccountAddress).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNTS, + mockScope, + MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, + ); + }); + + it('returns null when an error occurs', async () => { + const mockScope = Network.Localnet; + const mockRequest = { + id: 1, + jsonrpc: '2.0', + ...MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, + } as unknown as JsonRpcRequest; + const error = new Error('Something went wrong'); + + jest + .spyOn(mockWalletService, 'resolveAccountAddress') + .mockRejectedValue(error); + + const result = await keyring.resolveAccountAddress( + mockScope, + mockRequest, + ); + + expect(result).toBeNull(); + expect(trackError).toHaveBeenCalledWith(error); + }); + }); + + describe('submitRequest', () => { + it('throws an error if the account does not have the method', async () => { + const mockAccount = { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + methods: [], + scopes: [Network.Localnet], + }; + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(mockAccount); + + await expect( + keyring.submitRequest({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignAndSendTransaction, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + transaction: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + scope: Network.Localnet, + }, + }, + scope: Network.Localnet, + origin: 'https://metamask.io', + }), + ).rejects.toThrow( + `Method "${SolMethod.SignAndSendTransaction}" is not allowed for this account`, + ); + }); + + it('throws an error if the account does not have the scope', async () => { + const mockAccount = { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + scopes: [], + }; + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(mockAccount); + + await expect( + keyring.submitRequest({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignAndSendTransaction, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + transaction: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + scope: Network.Devnet, + }, + }, + scope: Network.Devnet, + origin: 'https://metamask.io', + }), + ).rejects.toThrow( + `Scope "${Network.Devnet}" is not allowed for this account`, + ); + }); + + it('throws an error if the scope does not match the request', async () => { + await expect( + keyring.submitRequest({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignAndSendTransaction, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + transaction: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + scope: Network.Devnet, + }, + }, + scope: Network.Mainnet, + origin: 'https://metamask.io', + }), + ).rejects.toThrow( + `Scope "${Network.Mainnet}" does not match "${Network.Devnet}" in request.params`, + ); + }); + + it('rejects when account address in request does not match signing account', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const request = { + account: account.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignMessage, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_3.address, + }, + message: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + }, + }, + scope: Network.Mainnet, + origin: 'https://metamask.io', + }; + jest + .spyOn(mockConfirmationHandler, 'handleKeyringRequest') + .mockResolvedValue(true); + + await expect(keyring.submitRequest(request)).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + + it('calls the confirmation handler, and calls the wallet service if confirmed', async () => { + jest + .spyOn(mockState, 'getKey') + .mockResolvedValueOnce(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + jest + .spyOn(mockConfirmationHandler, 'handleKeyringRequest') + .mockResolvedValue(true); + + const message = 'SGVsbG8sIHdvcmxkIQ=='; // "Hello, world!" in base64 + + const request: KeyringRequest = { + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignMessage, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + message, + }, + }, + scope: Network.Devnet, + origin: 'https://metamask.io', + }; + + await keyring.submitRequest(request); + + expect(mockConfirmationHandler.handleKeyringRequest).toHaveBeenCalledWith( + request, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(mockWalletService.signMessage).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + message, + ); + }); + + it('throws a UserRejectedRequestError if the confirmation handler returns false', async () => { + jest + .spyOn(mockConfirmationHandler, 'handleKeyringRequest') + .mockResolvedValue(false); + + const request: KeyringRequest = { + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + id: crypto.randomUUID(), + request: { + method: SolMethod.SignMessage, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + message: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + }, + }, + scope: Network.Devnet, + origin: 'https://metamask.io', + }; + + await expect(keyring.submitRequest(request)).rejects.toThrow( + 'User rejected the request.', + ); + }); + }); + + describe('createAccounts (bip44:discover)', () => { + it('returns an empty array and creates nothing when there is no on-chain activity', async () => { + mockTransactionsService.fetchLatestSignatures.mockResolvedValueOnce([]); + + const result = await keyring.createAccounts({ + type: AccountCreationType.Bip44Discover, + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + groupIndex: 10, + }); + + expect(result).toStrictEqual([]); + // SUPPORTED_SCOPES currently declares a single scope (Mainnet). + expect( + mockTransactionsService.fetchLatestSignatures, + ).toHaveBeenCalledTimes(1); + }); + + it('creates and returns the account when there is on-chain activity', async () => { + mockTransactionsService.fetchLatestSignatures.mockResolvedValueOnce([ + signature( + '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + ), + ]); + + const result = await keyring.createAccounts({ + type: AccountCreationType.Bip44Discover, + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + groupIndex: 10, + }); + + expect(result).toHaveLength(1); + }); + + it('throws an error if there is an error fetching transactions', async () => { + mockTransactionsService.fetchLatestSignatures.mockRejectedValue( + new Error('Network error'), + ); + + await expect( + keyring.createAccounts({ + type: AccountCreationType.Bip44Discover, + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + groupIndex: 10, + }), + ).rejects.toThrow('Network error'); + }); + }); + + describe('setSelectedAccounts', () => { + it('sets the monitored accounts', async () => { + const accountIds = MOCK_SOLANA_KEYRING_ACCOUNTS.map( + (account) => account.id, + ); + await keyring.setSelectedAccounts(accountIds); + + expect( + mockKeyringAccountMonitor.setMonitoredAccounts, + ).toHaveBeenCalledWith(accountIds); + }); + + it('rejects if an account id is not valid', async () => { + await expect( + keyring.setSelectedAccounts([ + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + 'not-a-uuid', + ]), + ).rejects.toThrow(InvalidParamsError); + }); + + it('rejects if an account id is not part of existing accounts', async () => { + await expect( + keyring.setSelectedAccounts([ + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + NON_EXISTENT_ACCOUNT_ID, + ]), + ).rejects.toThrow(InvalidParamsError); + + expect( + mockKeyringAccountMonitor.setMonitoredAccounts, + ).not.toHaveBeenCalled(); + }); + }); + + describe('exportAccount', () => { + /** + * Solana wallets accept the 64-byte secret key (seed[32] || publicKey[32]). + * The publicKey is the base58-decoded account address. + */ + const expectedSecretKey = new Uint8Array(64); + expectedSecretKey.set(MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, 0); + expectedSecretKey.set( + bs58.decode(MOCK_SOLANA_KEYRING_ACCOUNT_0.address), + 32, + ); + + it('exports the account private key as base58', async () => { + const result = await keyring.exportAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + { type: 'private-key', encoding: 'base58' }, + ); + + expect(result).toStrictEqual({ + type: 'private-key', + encoding: 'base58', + privateKey: bs58.encode(expectedSecretKey), + }); + }); + + it('encodes the 64-byte secret key (seed || publicKey)', async () => { + const result = await keyring.exportAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + { type: 'private-key', encoding: 'base58' }, + ); + + const decoded = bs58.decode(result.privateKey); + expect(decoded).toHaveLength(64); + expect(decoded.slice(0, 32)).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + ); + expect(decoded.slice(32)).toStrictEqual( + bs58.decode(MOCK_SOLANA_KEYRING_ACCOUNT_0.address), + ); + }); + + it('throws if the account id is not a uuid', async () => { + await expect( + keyring.exportAccount('not-a-uuid', { + type: 'private-key', + encoding: 'base58', + }), + ).rejects.toThrow(/Expected a string matching/u); + }); + + it('throws if the account is not found', async () => { + await expect( + keyring.exportAccount(NON_EXISTENT_ACCOUNT_ID, { + type: 'private-key', + encoding: 'base58', + }), + ).rejects.toThrow(`Account "${NON_EXISTENT_ACCOUNT_ID}" not found`); + }); + + it('rejects an unsupported encoding', async () => { + await expect( + keyring.exportAccount(MOCK_SOLANA_KEYRING_ACCOUNT_0.id, { + type: 'private-key', + encoding: 'utf-8' as unknown as 'base58', + }), + ).rejects.toThrow(/Expected/u); + }); + + it('rejects hexadecimal export encoding', async () => { + await expect( + keyring.exportAccount(MOCK_SOLANA_KEYRING_ACCOUNT_0.id, { + type: 'private-key', + encoding: 'hexadecimal', + }), + ).rejects.toThrow('Only base58 private key export is supported'); + }); + + it('rejects an unsupported export type', async () => { + await expect( + keyring.exportAccount(MOCK_SOLANA_KEYRING_ACCOUNT_0.id, { + type: 'mnemonic' as unknown as 'private-key', + encoding: 'base58', + }), + ).rejects.toThrow(/Expected/u); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts new file mode 100644 index 00000000..9dc6a985 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -0,0 +1,796 @@ +import { SLIP10Node } from '@metamask/key-tree'; +import { + AccountCreationType, + assertCreateAccountOptionIsSupported, + ListAccountAssetsResponseStruct, + SolAccountType, + SolMethod, + SolScope, +} from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + EntropySourceId, + Pagination, +} from '@metamask/keyring-api'; +import type { + Balance, + KeyringAccount, + KeyringRequest, + ResolvedAccountAddress, + Transaction, +} from '@metamask/keyring-api'; +import type { + ExportAccountOptions, + ExportedAccount, + KeyringSnapRpc, +} from '@metamask/keyring-api/v2'; +import type { CaipAssetType, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { + InvalidParamsError, + MethodNotFoundError, + SnapError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { array, assert, integer, is } from '@metamask/superstruct'; +import type { CaipChainId } from '@metamask/utils'; +import type { Signature } from '@solana/kit'; +import { address as asAddress, getAddressDecoder } from '@solana/kit'; +import bs58 from 'bs58'; +import { sortBy } from 'lodash'; + +import snapManifest from '../../../../snap.manifest.json'; +import { asStrictKeyringAccount } from '../../../entities'; +import type { SolanaKeyringAccount } from '../../../entities'; +import { SolanaCaip19Tokens } from '../../constants/solana'; +import type { Network } from '../../constants/solana'; +import type { + AssetsService, + KeyringAccountMonitor, + TransactionsService, +} from '../../services'; +import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; +import type { IStateManager } from '../../services/state/IStateManager'; +import type { UnencryptedStateValue } from '../../services/state/State'; +import { SolanaWalletRequestStruct } from '../../services/wallet/structs'; +import type { + SolanaSignAndSendTransactionResponse, + SolanaSignInResponse, + SolanaSignMessageResponse, + SolanaSignTransactionResponse, +} from '../../services/wallet/structs'; +import type { WalletService } from '../../services/wallet/WalletService'; +import { + deriveSolanaKeypair, + deriveSolanaKeypairFromCoinTypeNode, +} from '../../utils/deriveSolanaKeypair'; +import { trackError } from '../../utils/errors'; +import { getBip32Entropy } from '../../utils/getBip32Entropy'; +import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; +import { + endTrace, + listEntropySources, + startTrace, +} from '../../utils/interface'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { + Base58Struct, + DeleteAccountStruct, + ExportAccountRequestStruct, + GetAccounBalancesResponseStruct, + GetAccountBalancesStruct, + GetAccountStruct, + ListAccountAssetsStruct, + ListAccountTransactionsStruct, + NetworkStruct, + UuidStruct, +} from '../../validation/structs'; +import { validateRequest, validateResponse } from '../../validation/validators'; +import { SolanaKeyringRequestStruct } from './structs'; + +/** + * A Solana address decoder that we can reuse across the class to avoid instantiating multiple decoders. + */ +const decoder = getAddressDecoder(); + +const SUPPORTED_SCOPES = snapManifest.initialPermissions['endowment:keyring'] + .capabilities.scopes as readonly Network[]; + +type SubmitRequestResult = + | SolanaSignAndSendTransactionResponse + | SolanaSignTransactionResponse + | SolanaSignMessageResponse + | SolanaSignInResponse; + +export class SolanaKeyring implements KeyringSnapRpc { + readonly #state: IStateManager; + + readonly #logger: ILogger; + + readonly #transactionsService: TransactionsService; + + readonly #assetsService: AssetsService; + + readonly #walletService: WalletService; + + readonly #confirmationHandler: ConfirmationHandler; + + readonly #keyringAccountMonitor: KeyringAccountMonitor; + + readonly #traceName: string = 'Create Solana Account'; + + readonly #traceNameBatch: string = 'Create Solana Account Batch'; + + constructor({ + state, + logger, + transactionsService, + assetsService, + walletService, + confirmationHandler, + keyringAccountMonitor, + }: { + state: IStateManager; + logger: ILogger; + transactionsService: TransactionsService; + assetsService: AssetsService; + walletService: WalletService; + confirmationHandler: ConfirmationHandler; + keyringAccountMonitor: KeyringAccountMonitor; + }) { + this.#state = state; + this.#logger = createPrefixedLogger(logger, '[🔑 Keyring]'); + this.#transactionsService = transactionsService; + this.#assetsService = assetsService; + this.#walletService = walletService; + this.#confirmationHandler = confirmationHandler; + this.#keyringAccountMonitor = keyringAccountMonitor; + } + + async #listAccounts(): Promise { + try { + const keyringAccounts = + (await this.#state.getKey( + 'keyringAccounts', + )) ?? {}; + + return sortBy(Object.values(keyringAccounts), ['entropySource', 'index']); + } catch (error: any) { + // Note: we intentionally do not log here. The public callers + // (`getAccounts`, `createAccounts`, etc.) wrap calls to this helper in + // their own try/catch with a function-level log call, so logging here + // would produce duplicate `'Error listing accounts'` entries on every + // failure. We still rewrite to a stable error message so existing + // consumers keep their current observable behavior. The + // original error is attached as `cause` to preserve the underlying + // stack/details for debugging. + throw new Error('Error listing accounts', { cause: error }); + } + } + + async getAccount(accountId: string): Promise { + try { + validateRequest({ accountId }, GetAccountStruct); + + const account = await this.getAccountOrThrow(accountId); + + return asStrictKeyringAccount(account); + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account'); + throw new SnapError(error); + } + } + + /** + * Gets all accounts from the state. + * + * @returns The accounts. + */ + async getAccounts(): Promise { + try { + return (await this.#listAccounts()).map(asStrictKeyringAccount); + } catch (error: any) { + this.#logger.error({ error }, 'Error listing accounts'); + throw new SnapError(error); + } + } + + async getAccountOrThrow(accountId: string): Promise { + const account = await this.#getAccount(accountId); + if (!account) { + throw new Error(`Account "${accountId}" not found`); + } + + return account; + } + + async #getAccount( + accountId: string, + ): Promise { + return this.#state.getKey( + `keyringAccounts.${accountId}`, + ); + } + + #getDefaultDerivationPath(index: number): `m/${string}` { + return `m/44'/501'/${index}'/0'`; + } + + async #getDefaultEntropySource(): Promise { + const entropySources = await listEntropySources(); + const defaultEntropySource = entropySources.find(({ primary }) => primary); + + if (!defaultEntropySource) { + throw new Error( + 'No default entropy source found - this can never happen', + ); + } + + return defaultEntropySource.id; + } + + #buildKeyringAccount({ + id, + entropySource, + derivationPath, + index, + publicKeyBytes, + }: { + id: string; + entropySource: EntropySourceId; + derivationPath: `m/${string}`; + index: number; + publicKeyBytes: Uint8Array; + }): SolanaKeyringAccount { + const address = decoder.decode(publicKeyBytes.slice(1)); + + return { + id, + entropySource, + derivationPath, + index, + type: SolAccountType.DataAccount, + address, + scopes: [SolScope.Mainnet, SolScope.Testnet, SolScope.Devnet], + options: { + entropySource, + derivationPath, + index, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + }; + } + + async createAccounts( + options: CreateAccountOptions, + ): Promise { + try { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + `${AccountCreationType.Bip44Discover}`, + ]); + + await startTrace(this.#traceNameBatch); + + // Get entropy source + const entropySource = + options.entropySource ?? (await this.#getDefaultEntropySource()); + + // For discovery, only create the account if it has on-chain activity. No + // activity means we've reached the end of the discoverable accounts, so we + // return nothing and the client stops discovering. + if ( + options.type === AccountCreationType.Bip44Discover && + !(await this.#hasOnChainActivity(entropySource, options.groupIndex)) + ) { + await endTrace(this.#traceNameBatch); + return []; + } + + // Map existing accounts by group index + const allAccounts = new Map(); + for (const account of await this.#listAccounts()) { + if (account.entropySource === entropySource) { + allAccounts.set(account.index, account); + } + } + + // Create a range of group indexes to create accounts for + let range; + if (options.type === AccountCreationType.Bip44DeriveIndexRange) { + range = options.range; + } else { + // Bip44DeriveIndex | Bip44Discover — a single group index. Ranges are + // inclusive, so `from` and `to` are the same. + range = { from: options.groupIndex, to: options.groupIndex }; + } + + // Get coin-type node once (optimization: 1 snap API call for N accounts) + const coinTypeNodeJson = await getBip32Entropy({ + entropySource, + path: ['m', "44'", "501'"], + curve: 'ed25519', + }); + const coinTypeNode = await SLIP10Node.fromJSON(coinTypeNodeJson); + + // Create new accounts in memory, then flush all to state in one call + let createdCount = 0; + const newAccounts: Record = {}; + for (let groupIndex = range.from; groupIndex <= range.to; groupIndex++) { + if (!allAccounts.has(groupIndex)) { + const id = globalThis.crypto.randomUUID(); + const derivationPath = this.#getDefaultDerivationPath(groupIndex); + + // Derive keypair locally using key-tree (no additional snap API call) + const { publicKeyBytes } = await deriveSolanaKeypairFromCoinTypeNode({ + coinTypeNode, + accountIndex: groupIndex, + }); + + const solanaKeyringAccount = this.#buildKeyringAccount({ + id, + entropySource, + derivationPath, + index: groupIndex, + publicKeyBytes, + }); + + // Keep track of the new account in our local map to be able to return the full list + // of accounts at the end, sorted by group index. + allAccounts.set(groupIndex, solanaKeyringAccount); + + // Save the account in the snap state (defer actual saving until the end of the + // loop to minimize state writes). + newAccounts[id] = solanaKeyringAccount; + + createdCount += 1; + } + } + + // Single state write for all new accounts + await this.#state.setKeyWith>( + 'keyringAccounts', + (accounts) => ({ + ...accounts, + ...newAccounts, + }), + ); + + await endTrace(this.#traceNameBatch); + + // Assemble final result: all accounts sorted by group index + const result: KeyringAccount[] = []; + for (let groupIndex = range.from; groupIndex <= range.to; groupIndex++) { + const account = allAccounts.get(groupIndex); + if (account) { + result.push(asStrictKeyringAccount(account)); + } + } + + this.#logger.info( + `Created ${createdCount} new accounts, returned ${result.length} total accounts`, + ); + + return result; + } catch (error: any) { + this.#logger.error({ error }, 'Error creating accounts batch'); + throw new SnapError(error); + } + } + + async #deleteAccountFromState(accountId: string): Promise { + await Promise.all([ + this.#state.deleteKey(`keyringAccounts.${accountId}`), + this.#state.deleteKey(`transactions.${accountId}`), + this.#state.deleteKey(`assets.${accountId}`), + ]); + } + + async deleteAccount(accountId: string): Promise { + try { + validateRequest({ accountId }, DeleteAccountStruct); + + await this.#deleteAccountFromState(accountId); + } catch (error: any) { + this.#logger.error({ error }, 'Error deleting account'); + throw new SnapError(error); + } + } + + /** + * Returns the list of assets for the given account in all Solana networks. + * + * @param accountId - The id of the account. + * @returns CAIP-19 assets ids. + */ + async getAccountAssets(accountId: string): Promise { + try { + validateRequest({ accountId }, ListAccountAssetsStruct); + + const account = await this.getAccountOrThrow(accountId); + + const assetEntities = await this.#assetsService.findByAccount(account); + + const result = assetEntities + // Remove token assets with zero balance + .filter( + (asset) => + asset.assetType.endsWith(SolanaCaip19Tokens.SOL) || + Number(asset.rawAmount) > 0, + ) + .map((asset) => asset.assetType); + + validateResponse(result, ListAccountAssetsResponseStruct); + return result; + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account assets'); + throw new SnapError(error); + } + } + + /** + * Returns the balances and metadata of the given account for the given assets. + * + * @param accountId - The id of the account. + * @param assets - The assets to get the balances for (CAIP-19 ids). + * @returns The balances and metadata of the account for the given assets. + */ + async getAccountBalances( + accountId: string, + assets: CaipAssetType[], + ): Promise> { + try { + validateRequest({ accountId, assets }, GetAccountBalancesStruct); + + const account = await this.getAccountOrThrow(accountId); + + const assetsToUse = (await this.#assetsService.findByAccount(account)) + .filter((asset) => assets.includes(asset.assetType)) + // Remove token assets with zero balance + .filter( + (asset) => + asset.assetType.endsWith(SolanaCaip19Tokens.SOL) || + Number(asset.rawAmount) > 0, + ); + + const result = assetsToUse.reduce>( + (acc, asset) => { + acc[asset.assetType] = { + unit: asset.symbol, + amount: asset.uiAmount, + }; + return acc; + }, + {}, + ); + + validateResponse(result, GetAccounBalancesResponseStruct); + return result; + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account balances'); + throw new SnapError(error); + } + } + + async submitRequest(request: KeyringRequest): Promise { + assert(request, SolanaKeyringRequestStruct); + + const { + request: { method, params }, + scope, + account: accountId, + origin, + } = request; + + const account = await this.getAccountOrThrow(accountId); + + if (!account.scopes.includes(scope)) { + throw new Error(`Scope "${scope}" is not allowed for this account`); + } + + if (!account.methods.includes(method)) { + throw new Error(`Method "${method}" is not allowed for this account`); + } + + if ('scope' in params && scope !== params.scope) { + throw new Error( + `Scope "${scope}" does not match "${params.scope}" in request.params`, + ); + } + + assert(scope, NetworkStruct); + + const isConfirmed = await this.#confirmationHandler.handleKeyringRequest( + request, + account, + ); + + if (!isConfirmed) { + throw new UserRejectedRequestError() as unknown as Error; + } + + switch (method) { + case SolMethod.SignAndSendTransaction: { + const { transaction: base64EncodedTransaction, options } = params; + return this.#walletService.signAndSendTransaction( + account, + base64EncodedTransaction, + scope, + origin, + options, + ); + } + case SolMethod.SignTransaction: { + this.#validateAccountAddress(account, request); + const { transaction, options } = params; + return this.#walletService.signTransaction( + account, + transaction, + scope, + origin, + options, + ); + } + case SolMethod.SignMessage: { + this.#validateAccountAddress(account, request); + const { message } = params; + return this.#walletService.signMessage(account, message); + } + case SolMethod.SignIn: + return this.#walletService.signIn(account, params); + default: + throw new MethodNotFoundError( + `Unsupported method: ${method}`, + ) as unknown as Error; + } + } + + /** + * Validates that the account address in the request parameters matches the signing account. + * This prevents unauthorized account usage and authorization bypass. + * + * @param account - The account used for signing. + * @param request - The request containing the account address to validate. + * @throws If the account address is invalid or doesn't match the signing account. + */ + #validateAccountAddress( + account: SolanaKeyringAccount, + request: KeyringRequest, + ): void { + const { address } = account; + + const { account: requestAccount } = request.request.params as { + account: { address: string }; + }; + + try { + asAddress(requestAccount.address); + } catch { + throw new Error('Invalid Solana address format'); + } + + // Check that the account address in the request parameters matches the account used for signing + // If it doesn't match, throw the same error MM throws when the account is not authorized + if (requestAccount.address !== address) { + throw new Error( + 'The requested account and/or method has not been authorized by the user.', + ); + } + } + + /** + * Bootstrap the transactions for the given account. + * + * @param accountId - The id of the account. + * @param pagination - The pagination options. + * @param pagination.limit - The limit of the transactions to fetch. + * @param pagination.next - The next signature to fetch from. + * @returns The transactions for the given account. + */ + async getAccountTransactions( + accountId: string, + pagination: Pagination, + ): Promise<{ + data: Transaction[]; + next: Signature | null; + }> { + try { + validateRequest({ accountId, pagination }, ListAccountTransactionsStruct); + const { limit, next } = pagination; + + const keyringAccount = await this.#getAccount(accountId); + + if (!keyringAccount) { + throw new Error('Account not found'); + } + + const transactions = await this.#transactionsService.findByAccounts([ + keyringAccount, + ]); + + // Find the starting index based on the 'next' signature + const startIndex = next + ? transactions.findIndex((tx) => tx.id === next) + : 0; + + // Get transactions from startIndex to startIndex + limit + const accountTransactions = transactions.slice( + startIndex, + startIndex + limit, + ); + + // Determine the next signature for pagination + const hasMore = startIndex + pagination.limit < transactions.length; + const nextSignature = hasMore + ? ((transactions[startIndex + pagination.limit]?.id as Signature) ?? + null) + : null; + + return { + data: accountTransactions, + next: nextSignature, + }; + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account transactions'); + throw new SnapError(error); + } + } + + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param scope - Request's scope (CAIP-2). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address that must + * be used to process this signing request, or null if none candidates + * could be found. + */ + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + try { + assert(scope, NetworkStruct); + const { method, params } = request; + + const requestWithoutCommonHeader = { method, params }; + assert(requestWithoutCommonHeader, SolanaWalletRequestStruct); + + const allAccounts = await this.#listAccounts(); + + const caip10Address = await this.#walletService.resolveAccountAddress( + allAccounts, + scope, + requestWithoutCommonHeader, + ); + + return { address: caip10Address }; + } catch (error: any) { + await trackError(error); + this.#logger.error({ error }, 'Error resolving account address'); + return null; + } + } + + /** + * Checks whether the account at the given BIP-44 group index has any on-chain + * activity across the supported scopes. Drives `bip44:discover` account + * creation in {@link createAccounts}. + * + * The account is derived using the BIP-44 derivation path + * `m/44'/501'/${groupIndex}'/0'`, applied to the SRP referenced by the entropy + * source. + * + * @param entropySource - The entropy source aka Recovery Phrase. + * @param groupIndex - The group index to check for on-chain activity. + * @returns `true` if the derived address has at least one signature on any + * supported scope, `false` otherwise. + */ + async #hasOnChainActivity( + entropySource: EntropySourceId, + groupIndex: number, + ): Promise { + const derivationPath = this.#getDefaultDerivationPath(groupIndex); + + const { publicKeyBytes } = await deriveSolanaKeypair({ + entropySource, + derivationPath, + }); + const address = decoder.decode(publicKeyBytes.slice(1)); + + const scopeSignatures = await Promise.all( + SUPPORTED_SCOPES.map(async (scope) => + this.#transactionsService.fetchLatestSignatures(scope, address, { + limit: 1, + }), + ), + ); + + return scopeSignatures.some((signatures) => signatures.length > 0); + } + + /** + * Endpoint that the client can use to inform the snap that certain accounts are selected. + * + * @param accountIds - The ids of the accounts to set as selected. + */ + async setSelectedAccounts(accountIds: string[]): Promise { + validateRequest(accountIds, array(UuidStruct)); + + const existingIds = new Set( + (await this.#listAccounts()).map((account) => account.id), + ); + if (!accountIds.every((id) => existingIds.has(id))) { + throw new InvalidParamsError( + 'Account IDs were not part of existing accounts.', + ); + } + + await this.#keyringAccountMonitor.setMonitoredAccounts(accountIds); + } + + /** + * Exports an account from the state. This is used to export the private key of an account. + * This method is only triggered by the client when the user requests to export the private key of an account. + * + * @param accountId - The id of the account to export. + * @param options - The options for the export. + * @returns The exported account. + */ + async exportAccount( + accountId: string, + options?: ExportAccountOptions, + ): Promise { + validateRequest({ accountId, options }, ExportAccountRequestStruct); + + const account = await this.getAccountOrThrow(accountId); + + const encoding = options?.encoding ?? 'base58'; + if (encoding !== 'base58') { + throw new Error('Only base58 private key export is supported'); + } + + try { + const { privateKeyBytes, publicKeyBytes } = await deriveSolanaKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // Solana convention: 64-byte secret key = seed(32) || publicKey(32). + // publicKeyBytes is 33 bytes due to the SLIP-10 0x00 prefix; strip it. + const secretKey = new Uint8Array(64); + + secretKey.set(privateKeyBytes, 0); + secretKey.set(publicKeyBytes.slice(1), 32); + + const privateKey = bs58.encode(secretKey); + + // SECURITY: Use a boolean `is` check rather than an asserting validator + // here. A `StructError` thrown on failure would embed the offending value + // — the private key itself — in its message, leaking it into logs and to + // the caller. `is` returns a boolean, so we throw a value-free message. + if (!is(privateKey, Base58Struct)) { + throw new Error('Derived private key failed encoding validation'); + } + + return { + type: 'private-key', + encoding, + privateKey, + }; + } catch (error: any) { + const errorMsg = 'Error exporting account'; + this.#logger.error(errorMsg); + throw new SnapError(errorMsg); + } + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/structs.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/structs.ts new file mode 100644 index 00000000..9c8da038 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/structs.ts @@ -0,0 +1,34 @@ +import { KeyringRequestStruct } from '@metamask/keyring-api'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + min, + nonempty, + number, + object, + string, +} from '@metamask/superstruct'; + +import { SolanaWalletRequestStruct } from '../../services/wallet/structs'; +import { NetworkStruct } from '../../validation/structs'; + +/** + * A narrower type of the `KeyringRequestStruct` struct (think of it as `SolanaKeyringRequestStruct extends KeyringRequestStruct`) that is specific to the Solana snap. + */ +export const SolanaKeyringRequestStruct = object({ + ...KeyringRequestStruct.schema, // Re-use default schema as a base. + scope: NetworkStruct, // Narrow-down scopes for Solana only. + request: SolanaWalletRequestStruct, +}); + +export type SolanaKeyringRequest = Infer; + +export const DiscoverAccountsRequestStruct = object({ + scopes: nonempty(array(NetworkStruct)), + entropySource: string(), + groupIndex: min(number(), 0), +}); + +export type DiscoverAccountsRequest = Infer< + typeof DiscoverAccountsRequestStruct +>; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/onNameLookup.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/onNameLookup.ts new file mode 100644 index 00000000..2d4af786 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/onNameLookup.ts @@ -0,0 +1,58 @@ +import type { OnNameLookupHandler } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { nameResolutionService } from '../../../snapContext'; +import { SolanaNameLookupRequestStruct } from './structs'; + +const SOLANA_NAME_SERVICE_PROTOCOL = 'Solana Name Service'; + +export const onNameLookupHandler: OnNameLookupHandler = async (request) => { + assert(request, SolanaNameLookupRequestStruct); + + const { chainId, domain, address } = request; + + // regex to match valid .sol domains (at least one character before .sol) + const validDomainRegex = new RegExp( + `^.+\\${nameResolutionService.tld}$`, + 'u', + ); + + if (domain && validDomainRegex.test(domain)) { + const resolvedAddress = await nameResolutionService.resolveDomain( + chainId, + domain, + ); + + if (resolvedAddress) { + return { + resolvedAddresses: [ + { + resolvedAddress, + protocol: SOLANA_NAME_SERVICE_PROTOCOL, + domainName: domain, + }, + ], + }; + } + } + + if (address) { + const resolvedDomain = await nameResolutionService.resolveAddress( + chainId, + address, + ); + + if (resolvedDomain) { + return { + resolvedDomains: [ + { + resolvedDomain, + protocol: SOLANA_NAME_SERVICE_PROTOCOL, + }, + ], + }; + } + } + + return null; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/structs.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/structs.ts new file mode 100644 index 00000000..f123928a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onNameLookup/structs.ts @@ -0,0 +1,9 @@ +import { object, optional, string } from '@metamask/superstruct'; + +import { NetworkStruct } from '../../validation/structs'; + +export const SolanaNameLookupRequestStruct = object({ + chainId: NetworkStruct, + domain: optional(string()), + address: optional(string()), +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/onProtocolRequest.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/onProtocolRequest.ts new file mode 100644 index 00000000..bcade95b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/onProtocolRequest.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-case-declarations */ + +import type { OnProtocolRequestHandler } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { connection } from '../../../snapContext'; +import { NetworkStruct } from '../../validation/structs'; +import { + SolanaGetGenesisHashRequestStruct, + SolanaGetLatestBlockhashRequestStruct, + SolanaGetMinimumBalanceForRentExemptionRequestStruct, + SolanaProtocolRequestMethod, +} from './structs'; + +export const onProtocolRequest: OnProtocolRequestHandler = async ({ + scope, + request, +}) => { + assert(scope, NetworkStruct); + + switch (request.method) { + case SolanaProtocolRequestMethod.GetGenesisHash: + assert(request, SolanaGetGenesisHashRequestStruct); + return connection.getRpc(scope).getGenesisHash().send(); + + case SolanaProtocolRequestMethod.GetLatestBlockhash: + assert(request, SolanaGetLatestBlockhashRequestStruct); + const latestBlockhash = await connection.getLatestBlockhash(scope); + return latestBlockhash.blockhash; + + case SolanaProtocolRequestMethod.GetMinimumBalanceForRentExemption: + assert(request, SolanaGetMinimumBalanceForRentExemptionRequestStruct); + const minimumBalance = await connection + .getRpc(scope) + .getMinimumBalanceForRentExemption( + BigInt(request.params[0]), + request.params[1], + ) + .send(); + + return minimumBalance.toString(); + + default: + throw new Error(`Unsupported method: ${request.method}`); + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/structs.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/structs.ts new file mode 100644 index 00000000..0d270ebf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onProtocolRequest/structs.ts @@ -0,0 +1,37 @@ +import { + array, + literal, + object, + optional, + tuple, + number, +} from '@metamask/superstruct'; +import { JsonRpcIdStruct, JsonRpcVersionStruct } from '@metamask/utils'; + +export enum SolanaProtocolRequestMethod { + GetGenesisHash = 'getGenesisHash', + GetLatestBlockhash = 'getLatestBlockhash', + GetMinimumBalanceForRentExemption = 'getMinimumBalanceForRentExemption', +} + +export const SolanaGetGenesisHashRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(SolanaProtocolRequestMethod.GetGenesisHash), + params: optional(array()), +}); + +export const SolanaGetLatestBlockhashRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(SolanaProtocolRequestMethod.GetLatestBlockhash), +}); + +export const SolanaGetMinimumBalanceForRentExemptionRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal( + SolanaProtocolRequestMethod.GetMinimumBalanceForRentExemption, + ), + params: tuple([number(), optional(object())]), +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.test.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.test.ts new file mode 100644 index 00000000..e0088f5c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.test.ts @@ -0,0 +1,44 @@ +import { InternalError } from '@metamask/snaps-sdk'; + +import { Network } from '../../constants/solana'; +import { MOCK_VALID_SWAP_TRANSACTION } from '../../test/mocks/transactions-data/swap'; +import { TEST_ORIGIN } from '../../test/utils'; +import { getFeeForTransaction } from './getFeeForTransaction'; +import { RpcRequestMethod } from './types'; + +describe('getFeeForTransaction', () => { + const createArgs = (transaction: string) => ({ + origin: TEST_ORIGIN, + request: { + id: 'test-id', + method: RpcRequestMethod.GetFeeForTransaction, + jsonrpc: '2.0' as const, + params: { + transaction, + scope: Network.Localnet, + }, + }, + }); + + it('returns the fee for a transaction', async () => { + const args = createArgs(MOCK_VALID_SWAP_TRANSACTION); + + const result = await getFeeForTransaction(args); + + expect(result).toMatchObject({ + value: '57788', + }); + }); + + it('throws an error if transaction is not passed', async () => { + const args = createArgs(''); + + await expect(getFeeForTransaction(args)).rejects.toThrow(InternalError); + }); + + it('throws an error if transaction cannot be decoded', async () => { + const args = createArgs('not-a-transaction'); + + await expect(getFeeForTransaction(args)).rejects.toThrow(InternalError); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.ts new file mode 100644 index 00000000..a2c63858 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/getFeeForTransaction.ts @@ -0,0 +1,41 @@ +import { InternalError } from '@metamask/snaps-sdk'; +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { FeeCalculator } from '../../fees/FeeCalculator'; +import logger from '../../utils/logger'; +import { + GetFeeForTransactionParamsStruct, + GetFeeForTransactionResponseStruct, +} from '../../validation/structs'; + +/** + * Handles the computation of a fee for a transaction. + * + * @param args - The arguments for the request. + * @param args.request - The request object. + * @returns The response to the JSON-RPC request. + * @deprecated Use `ClientRequestMethod.ComputeFee` instead. + */ +export const getFeeForTransaction: OnRpcRequestHandler = async ({ + request, +}) => { + assert(request.params, GetFeeForTransactionParamsStruct); + + const { transaction } = request.params; + + try { + const { totalFee } = FeeCalculator.calculateFee(transaction); + + const result = { + value: totalFee.toString(), + }; + + assert(result, GetFeeForTransactionResponseStruct); + + return result; + } catch (error) { + logger.error(error); + throw new InternalError(error as string) as Error; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/index.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/index.ts new file mode 100644 index 00000000..c7156e5d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/index.ts @@ -0,0 +1,71 @@ +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; + +import { renderSend } from '../../../features/send/render'; +import { + accountsSynchronizer, + clientRequestHandler, + eventEmitter, + keyring, +} from '../../../snapContext'; +import { ClientRequestMethod } from '../onClientRequest'; +import { getFeeForTransaction } from './getFeeForTransaction'; +import { RpcRequestMethod, TestDappRpcRequestMethod } from './types'; + +export const handlers: Record = { + // TODO: Deprecate this method. + [RpcRequestMethod.GetFeeForTransaction]: getFeeForTransaction, + + [RpcRequestMethod.StartSendTransactionFlow]: renderSend, + // Methods specific to the test dapp + [TestDappRpcRequestMethod.ListWebSockets as any]: async () => { + await eventEmitter.emitSync('onListWebSockets'); + return null; + }, + [TestDappRpcRequestMethod.ListSubscriptions as any]: async () => { + await eventEmitter.emitSync('onListSubscriptions'); + return null; + }, + [TestDappRpcRequestMethod.TestOnStart as any]: async () => { + await eventEmitter.emitSync('onStart'); + return null; + }, + [TestDappRpcRequestMethod.TestOnInstall as any]: async () => { + await eventEmitter.emitSync('onInstall'); + return null; + }, + [TestDappRpcRequestMethod.TestOnUpdate as any]: async () => { + await eventEmitter.emitSync('onUpdate'); + return null; + }, + [TestDappRpcRequestMethod.SynchronizeAccounts as any]: async () => { + await accountsSynchronizer.synchronize(); + return null; + }, + [TestDappRpcRequestMethod.SetAccountSelected as any]: async ({ + request, + }: { + request: JsonRpcRequest; + }) => { + await keyring.setSelectedAccounts((request as any).params.accountIds); + return null; + }, + [TestDappRpcRequestMethod.ConfirmSend as any]: async ({ + request, + }: { + request: JsonRpcRequest; + }) => { + await clientRequestHandler.handle(request); + return null; + }, + [TestDappRpcRequestMethod.SignRewardsMessage as any]: async ({ + request, + }: { + request: JsonRpcRequest; + }) => { + return clientRequestHandler.handle({ + ...request, + method: ClientRequestMethod.SignRewardsMessage, + }); + }, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/types.ts b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/types.ts new file mode 100644 index 00000000..388414d3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/handlers/onRpcRequest/types.ts @@ -0,0 +1,20 @@ +export enum RpcRequestMethod { + StartSendTransactionFlow = 'startSendTransactionFlow', + GetFeeForTransaction = 'getFeeForTransaction', +} + +/** + * Methods specific to the test dapp, + * to allow specific flows for manual testing. + */ +export enum TestDappRpcRequestMethod { + ListWebSockets = 'listWebSockets', + ListSubscriptions = 'listSubscriptions', + TestOnStart = 'testOnStart', + TestOnInstall = 'testOnInstall', + TestOnUpdate = 'testOnUpdate', + SynchronizeAccounts = 'synchronizeAccounts', + SetAccountSelected = 'setAccountSelected', + ConfirmSend = 'confirmSend', + SignRewardsMessage = 'signRewardsMessage', +} diff --git a/merged-packages/solana-wallet-snap/src/core/img/question-mark.svg b/merged-packages/solana-wallet-snap/src/core/img/question-mark.svg new file mode 100644 index 00000000..2d3a50c3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/img/question-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.test.ts b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.test.ts new file mode 100644 index 00000000..0528c856 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.test.ts @@ -0,0 +1,119 @@ +import { + compileTransactionMessage, + getCompiledTransactionMessageEncoder, + pipe, +} from '@solana/kit'; +import type { TransactionMessageBytes } from '@solana/kit'; +import type { GetMultipleAccountsApi, Rpc } from '@solana/kit'; + +import { MOCK_EXECUTION_SCENARIOS } from '../services/signer/mocks/scenarios'; +import { + fromBase64StringToCompilableTransactionMessage, + fromBase64StringToTransaction, + fromBytesToCompilableTransactionMessage, + fromCompilableTransactionMessageToBase64String, + fromTransactionToBase64String, + fromUnknowBase64StringToTransactionOrTransactionMessage, +} from './codecs'; + +describe('codecs', () => { + describe.each(MOCK_EXECUTION_SCENARIOS)('scenarios', (scenario) => { + const { + name, + transactionMessage, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + getMultipleAccountsResponse, + } = scenario; + + const mockRpc = { + getMultipleAccounts: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(getMultipleAccountsResponse?.result), + }), + } as unknown as Rpc; + + describe(`fromCompilableTransactionMessageToBase64String | Scenario: ${name}`, () => { + it(`encodes a transaction message to a base64 string`, async () => { + const result = + await fromCompilableTransactionMessageToBase64String( + transactionMessage, + ); + + expect(result).toStrictEqual(transactionMessageBase64Encoded); + }); + }); + + describe(`fromBase64StringToCompilableTransactionMessage | Scenario: ${name}`, () => { + it('decodes a base64 string to a transaction message', async () => { + const result = await fromBase64StringToCompilableTransactionMessage( + transactionMessageBase64Encoded, + mockRpc, + ); + + expect(result).toStrictEqual(transactionMessage); + }); + }); + + describe('fromBytesToCompilableTransactionMessage', () => { + it('decodes bytes to a compilable transaction message', async () => { + const transactionMessageBytes = pipe( + transactionMessage, + compileTransactionMessage, + getCompiledTransactionMessageEncoder().encode, + ); + + const result = await fromBytesToCompilableTransactionMessage( + transactionMessageBytes as TransactionMessageBytes, + mockRpc, + ); + + expect(result).toStrictEqual(transactionMessage); + }); + }); + + describe('fromTransactionToBase64String', () => { + it('encodes a transaction to a base64 string', () => { + const result = fromTransactionToBase64String(signedTransaction); + + expect(result).toStrictEqual(signedTransactionBase64Encoded); + }); + }); + + describe(`fromBase64StringToTransaction | Scenario: ${name}`, () => { + it('decodes a base64 string to a transaction', async () => { + const result = await fromBase64StringToTransaction( + signedTransactionBase64Encoded, + ); + + const { lifetimeConstraint, ...rest } = signedTransaction; + + expect(result).toStrictEqual(rest); + }); + }); + + describe('fromUnknowBase64StringToTransactionOrTransactionMessage', () => { + it('decodes a base64 encoded transaction message to a transaction message', async () => { + const result = + await fromUnknowBase64StringToTransactionOrTransactionMessage( + transactionMessageBase64Encoded, + mockRpc, + ); + + expect(result).toStrictEqual(transactionMessage); + }); + + it('decodes a base64 encoded transaction to a transaction', async () => { + const result = + await fromUnknowBase64StringToTransactionOrTransactionMessage( + signedTransactionBase64Encoded, + mockRpc, + ); + + const { lifetimeConstraint, ...rest } = signedTransaction; + + expect(result).toStrictEqual(rest); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.ts b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.ts new file mode 100644 index 00000000..5d06954d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/codecs.ts @@ -0,0 +1,143 @@ +import type { Infer } from '@metamask/superstruct'; +import type { + CompilableTransactionMessage, + GetMultipleAccountsApi, + Rpc, + Transaction, + TransactionMessageBytes, +} from '@solana/kit'; +import { + compileTransactionMessage, + decompileTransactionMessageFetchingLookupTables, + getBase64Decoder, + getBase64Encoder, + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getTransactionDecoder, + getTransactionEncoder, + pipe, +} from '@solana/kit'; + +import { PromiseAny } from '../utils/PromiseAny'; +import type { Base64Struct } from '../validation/structs'; + +/** + * Encodes a compilable transaction message to a base64 string. + * + * @param compilableTransactionMessage - The compilable transaction message to convert. + * @returns The base64 encoded string. + */ +export const fromCompilableTransactionMessageToBase64String = async ( + compilableTransactionMessage: CompilableTransactionMessage, +): Promise> => + pipe( + compilableTransactionMessage, + // Compile it. + compileTransactionMessage, + // Convert the compiled message into a byte array. + getCompiledTransactionMessageEncoder().encode, + // Encode that byte array as a base64 string. + getBase64Decoder().decode, + ); + +export type DecompileTransactionMessageFetchingLookupTablesConfig = Parameters< + typeof decompileTransactionMessageFetchingLookupTables +>[2]; + +/** + * Decodes a base64 encoded string to a compilable transaction message. + * + * WARNING: Throws an error if the base64 encoded string is not a valid compilable transaction message. + * + * @param base64String - The base64 encoded string to decode. + * @param rpc - The RPC to use to fetch lookup tables. + * @param config - The configuration to use to fetch lookup tables. + * @returns The decoded compilable transaction message. + */ +export const fromBase64StringToCompilableTransactionMessage = async ( + base64String: Infer, + rpc: Rpc, + config?: DecompileTransactionMessageFetchingLookupTablesConfig, +): Promise => + pipe( + base64String, + getBase64Encoder().encode, + getCompiledTransactionMessageDecoder().decode, + async (decodedMessageBytes) => + decompileTransactionMessageFetchingLookupTables( + decodedMessageBytes, + rpc, + config, + ), + ); + +/** + * Decodes bytes to a compilable transaction message. + * + * WARNING: Throws an error if the bytes are not a valid compilable transaction message. + * + * @param messageBytes - The bytes to decode. + * @param rpc - The RPC to use to fetch lookup tables. + * @param config - The configuration to use to fetch lookup tables. + * @returns The decoded compilable transaction message. + */ +export const fromBytesToCompilableTransactionMessage = async ( + messageBytes: TransactionMessageBytes, + rpc: Rpc, + config?: DecompileTransactionMessageFetchingLookupTablesConfig, +): Promise => + pipe( + messageBytes, + getCompiledTransactionMessageDecoder().decode, + async (decodedMessageBytes) => + decompileTransactionMessageFetchingLookupTables( + decodedMessageBytes, + rpc, + config, + ), + ); + +/** + * Encodes a transaction to a base64 string. + * + * @param transaction - The transaction to encode. + * @returns The base64 encoded string. + */ +export const fromTransactionToBase64String = ( + transaction: Transaction, +): Infer => + pipe(transaction, getTransactionEncoder().encode, getBase64Decoder().decode); + +/** + * Decodes a base64 string to a transaction. + * + * WARNING: Throws an error if the base64 string is not a valid transaction. + * + * INFO: The lifetime constraint is not attached directly to the transaction, but it's + * present in the transaction message. + * + * @param base64String - The base64 string to decode. + * @returns The decoded transaction. + */ +export const fromBase64StringToTransaction = async ( + base64String: Infer, +): Promise => + pipe(base64String, getBase64Encoder().encode, getTransactionDecoder().decode); + +/** + * Decodes a base64 string to a transaction or a compilable transaction message. + * + * @param base64String - The base64 string to decode. + * @param rpc - The RPC to use to fetch lookup tables. + * @param config - The configuration to use to fetch lookup tables. + * @returns The decoded transaction or compilable transaction message. + */ +export const fromUnknowBase64StringToTransactionOrTransactionMessage = async ( + base64String: Infer, + rpc: Rpc, + config?: DecompileTransactionMessageFetchingLookupTablesConfig, +): Promise => + PromiseAny([ + fromBase64StringToTransaction(base64String), + fromBase64StringToCompilableTransactionMessage(base64String, rpc, config), + ]); diff --git a/merged-packages/solana-wallet-snap/src/core/sdk-extensions/rpc-api.ts b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/rpc-api.ts new file mode 100644 index 00000000..e6e46725 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/rpc-api.ts @@ -0,0 +1,16 @@ +import type { Address, JsonParsedTokenAccount } from '@solana/kit'; + +/** + * Re-create this type that is not exported from @solana/kit + */ +export type TokenAccountInfoWithJsonData = Readonly<{ + data: Readonly<{ + parsed: { + info: JsonParsedTokenAccount; + type: 'account'; + }; + /** Name of the program that owns this account. */ + program: Address; + space: bigint; + }>; +}>; diff --git a/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.test.ts b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.test.ts new file mode 100644 index 00000000..834e01ea --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.test.ts @@ -0,0 +1,472 @@ +import type { Rpc, SimulateTransactionApi } from '@solana/kit'; +import { + address, + blockhash, + getComputeUnitEstimateForTransactionMessageFactory, + SolanaError, +} from '@solana/kit'; + +import { Network } from '../constants/solana'; +import { createMockConnection } from '../services/mocks/mockConnection'; +import { + estimateAndOverrideComputeUnitLimit, + isComputeUnitLimitInstruction, + isComputeUnitPriceInstruction, + isTransactionMessageWithComputeUnitLimitInstruction, + isTransactionMessageWithComputeUnitPriceInstruction, + isTransactionMessageWithFeePayer, + setComputeUnitLimitInstructionIfMissing, + setComputeUnitPriceInstructionIfMissing, + setTransactionMessageFeePayerIfMissing, + setTransactionMessageLifetimeUsingBlockhashIfMissing, +} from './transaction-messages'; + +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + getComputeUnitEstimateForTransactionMessageFactory: jest + .fn() + .mockReturnValue(jest.fn().mockResolvedValue(500)), // 500 compute units +})); + +describe('transaction-messages', () => { + let rpc: Rpc; + + beforeEach(() => { + const mockConnection = createMockConnection(); + rpc = mockConnection.getRpc(Network.Testnet); + }); + + describe('isTransactionMessageWithFeePayer', () => { + it('returns true if the transaction message has a fee payer', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + feePayer: { + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + }, + }; + + expect(isTransactionMessageWithFeePayer(transactionMessage)).toBe(true); + }); + + it('returns false if the transaction message does not have a fee payer', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + }; + + expect(isTransactionMessageWithFeePayer(transactionMessage)).toBe(false); + }); + }); + + describe('setTransactionMessageFeePayerIfMissing', () => { + it('sets the fee payer if it is missing', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + }; + const feePayer = address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'); + + const result = setTransactionMessageFeePayerIfMissing( + feePayer, + transactionMessage, + ); + + expect(result).toStrictEqual({ + version: 0 as const, + instructions: [], + feePayer: { + address: feePayer, + }, + }); + }); + + it('does not set the fee payer if it is already present', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + feePayer: { + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + }, + }; + const feePayerWontBeSet = address( + 'FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo', + ); + + const result = setTransactionMessageFeePayerIfMissing( + feePayerWontBeSet, + transactionMessage, + ); + + expect(result).toStrictEqual(transactionMessage); + }); + }); + + describe('setTransactionMessageLifetimeUsingBlockhashIfMissing', () => { + it('sets the lifetime using blockhash if it is missing', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + }; + const lifetimeConstraint = { + blockhash: blockhash('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + lastValidBlockHeight: 18446744073709551615n, + }; + + const result = setTransactionMessageLifetimeUsingBlockhashIfMissing( + lifetimeConstraint, + transactionMessage, + ); + + expect(result).toStrictEqual({ + version: 0 as const, + instructions: [], + lifetimeConstraint, + }); + }); + + it('does not set the lifetime if it is already present', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + lifetimeConstraint: { + blockhash: blockhash('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + lastValidBlockHeight: 18446744073709551615n, + }, + }; + const lifetimeConstraintWontBeSet = { + blockhash: blockhash('8vMXV3ERvs12BY8w1nSHutzwwMptAR5UvUSq5pH2QYsK'), + lastValidBlockHeight: 18446744073709551615n, + }; + + const result = setTransactionMessageLifetimeUsingBlockhashIfMissing( + lifetimeConstraintWontBeSet, + transactionMessage, + ); + + expect(result).toStrictEqual(transactionMessage); + }); + }); + + describe('isComputeUnitPriceInstruction', () => { + it('returns true if the instruction is a compute unit price instruction', () => { + // Is a compute unit price instruction because program address is the compute budget program address and the opcode is 3 + const instruction = { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([3, 44, 1, 0, 0]), + }; + + expect(isComputeUnitPriceInstruction(instruction)).toBe(true); + }); + + it('returns false if the instruction is not program address is not the compute budget program address', () => { + const instruction = { + programAddress: address('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + data: Uint8Array.from([3, 44, 1, 0, 0]), + }; + + expect(isComputeUnitPriceInstruction(instruction)).toBe(false); + }); + + it('returns false if the instruction is not a data opcode is not 3', () => { + const instruction = { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 44, 1, 0, 0]), + }; + + expect(isComputeUnitPriceInstruction(instruction)).toBe(false); + }); + }); + + describe('isTransactionMessageWithComputeUnitPriceInstruction', () => { + it('returns true if the transaction message has a compute unit price instruction', () => { + const transactionMessage = { + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([3, 44, 1, 0, 0]), + }, + ], + }; + + expect( + isTransactionMessageWithComputeUnitPriceInstruction(transactionMessage), + ).toBe(true); + }); + + it('returns false if the transaction message does not have a compute unit price instruction', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + }; + + expect( + isTransactionMessageWithComputeUnitPriceInstruction(transactionMessage), + ).toBe(false); + }); + }); + + describe('setComputeUnitPriceInstructionIfMissing', () => { + it('sets the compute unit price instruction if it is missing', () => { + const transaction = { + version: 0 as const, + instructions: [], + }; + const input = { + microLamports: 1000000n, + }; + + const result = setComputeUnitPriceInstructionIfMissing( + transaction, + input, + ); + + expect(result).toStrictEqual({ + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([3, 64, 66, 15, 0, 0, 0, 0, 0]), + }, + ], + }); + }); + + it('does not set the compute unit price instruction if it is already present', () => { + const transaction = { + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([3, 64, 66, 15, 0, 0, 0, 0, 0]), // 1000000n microLamports + }, + ], + }; + const input = { + microLamports: 55555n, // Some other value + }; + + const result = setComputeUnitPriceInstructionIfMissing( + transaction, + input, + ); + + expect(result).toStrictEqual(transaction); + }); + }); + + describe('isComputeUnitLimitInstruction', () => { + it('returns true if the instruction is a compute unit limit instruction', () => { + // Is a compute unit limit instruction because program address is the compute budget program address and the opcode is 2 + const instruction = { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 44, 1, 0, 0]), + }; + + expect(isComputeUnitLimitInstruction(instruction)).toBe(true); + }); + + it('returns false if the instruction is not program address is not the compute budget program address', () => { + const instruction = { + programAddress: address('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + data: Uint8Array.from([2, 44, 1, 0, 0]), + }; + + expect(isComputeUnitLimitInstruction(instruction)).toBe(false); + }); + + it('returns false if the instruction is not a data opcode is not 2', () => { + const instruction = { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([3, 44, 1, 0, 0]), + }; + + expect(isComputeUnitLimitInstruction(instruction)).toBe(false); + }); + }); + + describe('isTransactionMessageWithComputeUnitLimitInstruction', () => { + it('returns true if the transaction message has a compute unit limit instruction', () => { + const transactionMessage = { + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([2, 44, 1, 0, 0]), + }, + ], + }; + + expect( + isTransactionMessageWithComputeUnitLimitInstruction(transactionMessage), + ).toBe(true); + }); + + it('returns false if the transaction message does not have a compute unit limit instruction', () => { + const transactionMessage = { + version: 0 as const, + instructions: [], + }; + + expect( + isTransactionMessageWithComputeUnitLimitInstruction(transactionMessage), + ).toBe(false); + }); + }); + + describe('setComputeUnitLimitInstructionIfMissing', () => { + it('sets the compute unit limit instruction if it is missing', () => { + const transaction = { + version: 0 as const, + instructions: [], + }; + const input = { + units: 1000000, + }; + + const result = setComputeUnitLimitInstructionIfMissing( + transaction, + input, + ); + + expect(result).toStrictEqual({ + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([2, 64, 66, 15, 0]), + }, + ], + }); + }); + + it('does not set the compute unit limit instruction if it is already present', () => { + const transaction = { + version: 0 as const, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([2, 64, 66, 15, 0]), // 1000000 units + }, + ], + }; + const input = { + units: 55555, // Some other value + }; + + const result = setComputeUnitLimitInstructionIfMissing( + transaction, + input, + ); + + expect(result).toStrictEqual(transaction); + }); + }); + + describe('estimateAndOverrideComputeUnitLimit', () => { + const transactionMessageWithNoComputeUnitLimit = { + version: 0 as const, + feePayer: { + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + }, + lifetimeConstraint: { + blockhash: blockhash('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + lastValidBlockHeight: 18446744073709551615n, + }, + instructions: [], + }; + + it('estimates the compute unit limit and sets it on a transaction message with no previous compute unit limit instruction', async () => { + const result = await estimateAndOverrideComputeUnitLimit( + transactionMessageWithNoComputeUnitLimit, + rpc, + ); + + expect(result.instructions).toHaveLength(1); + expect(result.instructions[0]).toStrictEqual({ + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 244, 1, 0, 0]), + }); + }); + + it('overrides the existing compute unit limit instruction if it is present', async () => { + const transactionMessageWithComputeUnitLimit = { + ...transactionMessageWithNoComputeUnitLimit, + instructions: [ + { + programAddress: address( + 'ComputeBudget111111111111111111111111111111', + ), + data: Uint8Array.from([2, 123, 4, 0, 0]), + }, + ], + }; + + const result = await estimateAndOverrideComputeUnitLimit( + transactionMessageWithComputeUnitLimit, + rpc, + ); + + expect(result.instructions).toHaveLength(1); + expect(result.instructions[0]).toStrictEqual({ + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 244, 1, 0, 0]), + }); + }); + + it('returns the units consumed from the error if the transaction simulation failed', async () => { + jest + .mocked(getComputeUnitEstimateForTransactionMessageFactory) + .mockReturnValue( + jest.fn().mockRejectedValue( + // This code is the specific error code for failed transaction simulation when estimating the compute unit limit. + // It ensures that the error includes the units consumed. + new SolanaError(5663019, { + unitsConsumed: 150, + }), + ), + ); + + const result = await estimateAndOverrideComputeUnitLimit( + transactionMessageWithNoComputeUnitLimit, + rpc, + ); + + expect(result.instructions[0]).toStrictEqual({ + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 150, 0, 0, 0]), // 150 units + }); + }); + + it('returns the original transaction message if the compute unit limit cannot be estimated', async () => { + jest + .mocked(getComputeUnitEstimateForTransactionMessageFactory) + .mockReturnValue( + jest.fn().mockRejectedValue( + // Some other error code not related to compute unit limit estimation. The error doesn't contain the units consumed. + new SolanaError(8190003), + ), + ); + + const result = await estimateAndOverrideComputeUnitLimit( + transactionMessageWithNoComputeUnitLimit, + rpc, + ); + + expect(result.instructions).toHaveLength(0); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.ts b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.ts new file mode 100644 index 00000000..ef88b570 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/sdk-extensions/transaction-messages.ts @@ -0,0 +1,277 @@ +import type { + SetComputeUnitLimitInput, + SetComputeUnitPriceInput, +} from '@solana-program/compute-budget'; +import { + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ComputeBudgetInstruction, + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, + identifyComputeBudgetInstruction, +} from '@solana-program/compute-budget'; +import type { + Address, + BaseTransactionMessage, + CompilableTransactionMessage, + IInstruction, + ITransactionMessageWithFeePayer, + Rpc, + SimulateTransactionApi, + TransactionMessageWithBlockhashLifetime, +} from '@solana/kit'; +import { + getComputeUnitEstimateForTransactionMessageFactory, + isSolanaError, + isTransactionMessageWithBlockhashLifetime, + prependTransactionMessageInstructions, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_COMPUTE_LIMIT, +} from '@solana/kit'; +import { cloneDeep } from 'lodash'; + +/** + * Check if the transaction message has a fee payer. + * + * @param transactionMessage - The transaction message to check. + * @returns `true` if the transaction message has a fee payer, `false` otherwise. + */ +export const isTransactionMessageWithFeePayer = < + TTransactionMessage extends BaseTransactionMessage & + Partial, +>( + transactionMessage: TTransactionMessage, +): transactionMessage is TTransactionMessage & + ITransactionMessageWithFeePayer => Boolean(transactionMessage.feePayer); + +/** + * Set the fee payer for the transaction message if it is missing. + * + * @param feePayer - The fee payer to set. + * @param transactionMessage - The transaction message to set the fee payer for. + * @returns The transaction message with the fee payer set. + */ +export const setTransactionMessageFeePayerIfMissing = < + TFeePayerAddress extends string, + TTransactionMessage extends BaseTransactionMessage & + Partial, +>( + feePayer: Address, + transactionMessage: TTransactionMessage, +) => + isTransactionMessageWithFeePayer(transactionMessage) + ? transactionMessage + : setTransactionMessageFeePayer(feePayer, transactionMessage); + +/** + * Set the lifetime constraint for the transaction message if it is missing. + * + * @param blockhashLifetimeConstraint - The blockhash lifetime constraint to set. + * @param transaction - The transaction message to set the lifetime constraint for. + * @returns The transaction message with the lifetime constraint set. + */ +export const setTransactionMessageLifetimeUsingBlockhashIfMissing = < + TTransaction extends + | BaseTransactionMessage + | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), +>( + blockhashLifetimeConstraint: TransactionMessageWithBlockhashLifetime['lifetimeConstraint'], + transaction: TTransaction, +) => + isTransactionMessageWithBlockhashLifetime(transaction) + ? transaction + : setTransactionMessageLifetimeUsingBlockhash( + blockhashLifetimeConstraint, + transaction, + ); + +/** + * Get a predicate function that checks if an instruction is a compute unit price instruction. + * + * @param instruction - The instruction to check. + * @returns A predicate function that checks if an instruction is a compute unit price instruction. + */ +export const isComputeUnitPriceInstruction = (instruction: IInstruction) => + instruction.programAddress === COMPUTE_BUDGET_PROGRAM_ADDRESS && + identifyComputeBudgetInstruction({ + data: new Uint8Array(), // Provide a default value for instruction.data it can be undefined + ...instruction, + }) === ComputeBudgetInstruction.SetComputeUnitPrice; + +/** + * Check if the transaction message has a compute unit price instruction. + * + * @param transaction - The transaction message to check. + * @returns `true` if the transaction message has a compute unit price instruction, `false` otherwise. + */ +export const isTransactionMessageWithComputeUnitPriceInstruction = < + TTransaction extends BaseTransactionMessage, +>( + transaction: TTransaction, +): boolean => + transaction.instructions.filter(Boolean).some(isComputeUnitPriceInstruction); + +/** + * Add a compute unit price instruction to the transaction message if it is missing. + * + * @param transaction - The transaction message to add the compute unit price instruction to. + * @param input - The input for the compute unit price instruction. + * @param config - Optional config for the compute unit price instruction. + * @param config.programAddress - The program address to check for the compute unit price instruction. + * @returns The transaction message with the compute unit price instruction added. + */ +export const setComputeUnitPriceInstructionIfMissing = < + TTransaction extends BaseTransactionMessage, +>( + transaction: TTransaction, + input: SetComputeUnitPriceInput, + config?: { + programAddress?: Address; + }, +): TTransaction => { + if (isTransactionMessageWithComputeUnitPriceInstruction(transaction)) { + return transaction; + } + return prependTransactionMessageInstructions( + [getSetComputeUnitPriceInstruction(input, config)], + transaction, + ); +}; + +/** + * Get a predicate function that checks if an instruction is a compute unit limit instruction. + * + * @param instruction - The instruction to check. + * @returns A predicate function that checks if an instruction is a compute unit limit instruction. + */ +export const isComputeUnitLimitInstruction = (instruction: IInstruction) => + instruction.programAddress === COMPUTE_BUDGET_PROGRAM_ADDRESS && + identifyComputeBudgetInstruction({ + data: new Uint8Array(), // Provide a default value for instruction.data it can be undefined + ...instruction, + }) === ComputeBudgetInstruction.SetComputeUnitLimit; + +/** + * Check if the transaction message has a compute unit limit instruction. + * + * @param transaction - The transaction message to check. + * @returns `true` if the transaction message has a compute unit limit instruction, `false` otherwise. + */ +export const isTransactionMessageWithComputeUnitLimitInstruction = < + TTransaction extends BaseTransactionMessage, +>( + transaction: TTransaction, +): boolean => + transaction.instructions.filter(Boolean).some(isComputeUnitLimitInstruction); + +/** + * Add a compute unit limit instruction to the transaction message if it is missing. + * + * @param transaction - The transaction message to add the compute unit limit instruction to. + * @param input - The input for the compute unit limit instruction. + * @param config - Optional config for the compute unit limit instruction. + * @param config.programAddress - The program address to check for the compute unit limit instruction. + * @returns The transaction message with the compute unit limit instruction added. + */ +export const setComputeUnitLimitInstructionIfMissing = < + TTransaction extends BaseTransactionMessage, +>( + transaction: TTransaction, + input: SetComputeUnitLimitInput, + config?: { + programAddress?: Address; + }, +): TTransaction => { + if (isTransactionMessageWithComputeUnitLimitInstruction(transaction)) { + return transaction; + } + return prependTransactionMessageInstructions( + [getSetComputeUnitLimitInstruction({ units: input.units }, config)], + transaction, + ); +}; + +export type ComputeUnitEstimateForTransactionMessageConfig = Omit< + Parameters< + ReturnType + >[1], + 'rpc' | 'transactionMessage' +>; + +export type SetComputeUnitLimitInstructionConfig = Parameters< + typeof getSetComputeUnitLimitInstruction +>[1]; + +export type EstimateAndOverrideComputeUnitLimitConfig = { + getComputeUnitEstimateConfig?: ComputeUnitEstimateForTransactionMessageConfig; + getSetComputeUnitLimitInstructionConfig?: SetComputeUnitLimitInstructionConfig; +}; + +/** + * Estimate the compute unit limit for the transaction message and set it, + * overriding the existing compute unit limit instruction. + * + * If the transaction fails to simulate, we recover the units consumed from the error, + * and consider that as the compute unit limit. + * + * If the estimate fails for any other reason, return the original transaction message unchanged. + * + * @param transactionMessage - The transaction message to estimate the compute unit limit for. + * @param rpc - The RPC to use to estimate the compute unit limit. + * @param config - Optional config for the compute unit limit instruction. + * @param config.getComputeUnitEstimateConfig - Optional config for the compute unit estimate. + * @param config.getSetComputeUnitLimitInstructionConfig - Optional config for the set compute unit limit instruction. + * @returns The transaction message with the compute unit limit instruction added. + */ +export const estimateAndOverrideComputeUnitLimit = async ( + transactionMessage: CompilableTransactionMessage, + rpc: Rpc, + config?: { + getComputeUnitEstimateConfig?: ComputeUnitEstimateForTransactionMessageConfig; + getSetComputeUnitLimitInstructionConfig?: SetComputeUnitLimitInstructionConfig; + }, +): Promise => { + try { + const instructions = transactionMessage.instructions.filter(Boolean); + + const getComputeUnitEstimate = + getComputeUnitEstimateForTransactionMessageFactory({ + rpc, + }); + + const units = await getComputeUnitEstimate( + transactionMessage, + config?.getComputeUnitEstimateConfig, + ).catch((error) => { + // If the transaction simulation failed, we recover the units consumed from the error, and consider that as the compute unit limit. + if ( + isSolanaError( + error, + SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_COMPUTE_LIMIT, + ) + ) { + return error.context.unitsConsumed; + } + + // Otherwise it's an unexpected error. Rethrow. + throw error; + }); + + // Recreate the transaction message, replacing the compute unit limit instruction with the new one. + return { + ...cloneDeep(transactionMessage), + instructions: [ + ...instructions.filter( + (instruction) => !isComputeUnitLimitInstruction(instruction), + ), + getSetComputeUnitLimitInstruction( + { units }, + config?.getSetComputeUnitLimitInstructionConfig, + ), + ], + } as CompilableTransactionMessage; + } catch (error) { + // If the estimate fails, return the original transaction message unchanged. + return transactionMessage; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.test.ts b/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.test.ts new file mode 100644 index 00000000..34362a2d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.test.ts @@ -0,0 +1,95 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import BigNumber from 'bignumber.js'; + +import { deserialize } from './deserialize'; + +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])); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.ts b/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.ts new file mode 100644 index 00000000..4e352e44 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/serialization/deserialize.ts @@ -0,0 +1,37 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { getBase64Codec } from '@solana/kit'; +import BigNumber from 'bignumber.js'; + +import type { Serializable } from './types'; + +/** + * Deserializes the passed value from a JSON object to an object with its the 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') { + return getBase64Codec().encode(value.value); + } + + return value; + }); diff --git a/merged-packages/solana-wallet-snap/src/core/serialization/serialize.test.ts b/merged-packages/solana-wallet-snap/src/core/serialization/serialize.test.ts new file mode 100644 index 00000000..85dbda83 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/serialization/serialize.test.ts @@ -0,0 +1,105 @@ +import BigNumber from 'bignumber.js'; + +import { serialize } from './serialize'; + +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', + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/serialization/serialize.ts b/merged-packages/solana-wallet-snap/src/core/serialization/serialize.ts new file mode 100644 index 00000000..824003cf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/serialization/serialize.ts @@ -0,0 +1,47 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { getBase64Codec } from '@solana/kit'; +import BigNumber from 'bignumber.js'; +import { cloneDeepWith } from 'lodash'; + +import type { Serializable } from './types'; + +/** + * 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): Record => + cloneDeepWith(value, (val) => { + 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) { + return { + __type: 'Uint8Array', + value: getBase64Codec().decode(val), + }; + } + + // Return undefined to let lodash handle the cloning of other values + return undefined; + }); diff --git a/merged-packages/solana-wallet-snap/src/core/serialization/types.ts b/merged-packages/solana-wallet-snap/src/core/serialization/types.ts new file mode 100644 index 00000000..ad86620e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/serialization/types.ts @@ -0,0 +1,17 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type BigNumber from 'bignumber.js'; + +/** + * 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; + }; diff --git a/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts new file mode 100644 index 00000000..c658e579 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts @@ -0,0 +1,38 @@ +import type { SolanaKeyringAccount } from '../../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AccountsRepository { + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + async getAll(): Promise { + const accounts = + await this.#state.getKey( + 'keyringAccounts', + ); + + return Object.values(accounts ?? {}); + } + + async findById(id: string): Promise { + return (await this.#state.getKey(`keyringAccounts.${id}`)) ?? null; + } + + async findByAddress(address: string): Promise { + const accounts = await this.getAll(); + + return accounts.find((account) => account.address === address) ?? null; + } + + async save(account: SolanaKeyringAccount): Promise { + await this.#state.setKey(`keyringAccounts.${account.id}`, account); + } + + async delete(id: string): Promise { + await this.#state.deleteKey(`keyringAccounts.${id}`); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts new file mode 100644 index 00000000..3b78a6c9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts @@ -0,0 +1,43 @@ +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { AccountsRepository } from './AccountsRepository'; + +export class AccountsService { + readonly #accountsRepository: AccountsRepository; + + constructor(accountsRepository: AccountsRepository) { + this.#accountsRepository = accountsRepository; + } + + async getAll(): Promise { + return this.#accountsRepository.getAll(); + } + + async getAllSelected(): Promise { + const [allAccounts, selectedAccountIds] = await Promise.all([ + this.#accountsRepository.getAll(), + getSelectedAccounts(snap), + ]); + + return allAccounts.filter((account) => + selectedAccountIds.includes(account.id), + ); + } + + async findById(id: string): Promise { + return this.#accountsRepository.findById(id); + } + + async findByAddress(address: string): Promise { + return this.#accountsRepository.findByAddress(address); + } + + async save(account: SolanaKeyringAccount): Promise { + return this.#accountsRepository.save(account); + } + + async delete(id: string): Promise { + return this.#accountsRepository.delete(id); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts new file mode 100644 index 00000000..d214690c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts @@ -0,0 +1,53 @@ +import type { SolanaKeyringAccount } from '../../../entities'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { AssetsService } from '../assets/AssetsService'; +import type { TransactionsService } from '../transactions'; +import type { AccountsService } from './AccountsService'; + +export class AccountsSynchronizer { + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #transactionsService: TransactionsService; + + readonly #logger: ILogger; + + constructor( + accountsService: AccountsService, + assetsService: AssetsService, + transactionsService: TransactionsService, + logger: ILogger, + ) { + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#transactionsService = transactionsService; + this.#logger = createPrefixedLogger(logger, '[🔄 AccountsSynchronizer]'); + } + + async synchronize(accounts?: SolanaKeyringAccount[]): Promise { + const accountsToSync = accounts ?? (await this.#accountsService.getAll()); + + this.#logger.info('Synchronizing accounts', accountsToSync); + + const assets = ( + await Promise.allSettled( + accountsToSync.map(async (account) => + this.#assetsService.fetch(account), + ), + ) + ) + .map((item) => (item.status === 'fulfilled' ? item.value : [])) + .flat(); + + await this.#assetsService.saveMany(assets); + + const transactions = + await this.#transactionsService.fetchAssetsTransactions(assets, { + limit: 20, + }); + + await this.#transactionsService.saveMany(transactions); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/accounts/index.ts b/merged-packages/solana-wallet-snap/src/core/services/accounts/index.ts new file mode 100644 index 00000000..2d8db8ff --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/accounts/index.ts @@ -0,0 +1,3 @@ +export * from './AccountsRepository'; +export * from './AccountsService'; +export * from './AccountsSynchronizer'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.test.ts new file mode 100644 index 00000000..4c30a8ac --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.test.ts @@ -0,0 +1,360 @@ +import type { Transaction } from '@metamask/keyring-api'; + +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import { trackError } from '../../utils/errors'; +import { ScanStatus, SecurityAlertResponse } from '../transaction-scan/types'; +import { AnalyticsService } from './AnalyticsService'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +const mockSnapRequest = jest.fn(); +const snap = { + request: mockSnapRequest, +}; +(globalThis as any).snap = snap; + +describe('AnalyticsService', () => { + let analyticsService: AnalyticsService; + + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockScope = Network.Mainnet; + const mockOrigin = 'https://metamask.io'; + const mockSignature = 'mockedSignature'; + const mockMetadata = { + scope: mockScope, + origin: mockOrigin, + }; + + beforeEach(() => { + jest.clearAllMocks(); + analyticsService = new AnalyticsService(); + mockSnapRequest.mockResolvedValue(undefined); + }); + + describe('trackEventTransactionAdded', () => { + it('tracks transaction added event with origin', async () => { + await analyticsService.trackEventTransactionAdded( + mockAccount, + mockMetadata, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Transaction Added', + properties: { + message: 'Snap transaction added', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + }, + }, + }, + }); + }); + + it('tracks analytics failures', async () => { + const error = new Error('Tracking failed'); + mockSnapRequest.mockRejectedValueOnce(error); + + await analyticsService.trackEventTransactionAdded( + mockAccount, + mockMetadata, + ); + + expect(trackError).toHaveBeenCalledWith(error); + }); + }); + + describe('trackEventTransactionApproved', () => { + it('tracks transaction approved event with origin', async () => { + await analyticsService.trackEventTransactionApproved( + mockAccount, + mockMetadata, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Transaction Approved', + properties: { + message: 'Snap transaction approved', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + }, + }, + }, + }); + }); + }); + + describe('trackEventTransactionSubmitted', () => { + it('tracks transaction submitted event with origin', async () => { + await analyticsService.trackEventTransactionSubmitted( + mockAccount, + mockSignature, + mockMetadata, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Transaction Submitted', + properties: { + message: 'Snap transaction submitted', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + }, + }, + }, + }); + }); + }); + + describe('trackEventTransactionFinalized', () => { + const mockTransaction: Transaction = { + id: 'mock-transaction-id', + account: mockAccount.id, + chain: mockScope, + status: 'confirmed', + type: 'send', + timestamp: 1736500242, + from: [ + { + address: mockAccount.address, + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.1', + }, + }, + ], + to: [ + { + address: '6LfawjK4CQE7pHApWYA6s6PCH5jfgrcRcV6xE6vtiyjY', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.1', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1736500242, + }, + ], + }; + + it('tracks transaction finalized event with origin', async () => { + await analyticsService.trackEventTransactionFinalized( + mockAccount, + mockTransaction, + mockMetadata, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Transaction Finalized', + properties: { + message: 'Snap transaction finalized', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockTransaction.chain, + transaction_status: mockTransaction.status, + transaction_type: mockTransaction.type, + }, + }, + }, + }); + }); + }); + + describe('trackEventTransactionRejected', () => { + it('tracks transaction rejected event with origin', async () => { + await analyticsService.trackEventTransactionRejected( + mockAccount, + mockMetadata, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Transaction Rejected', + properties: { + message: 'Snap transaction rejected', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + }, + }, + }, + }); + }); + }); + + describe('trackEventSecurityAlertDetected', () => { + it('tracks security alert detected event', async () => { + const securityAlertResponse = SecurityAlertResponse.Warning; + const securityAlertReason = 'transfer_farming'; + const securityAlertDescription = + "Substantial transfer of the account's assets to untrusted entities"; + + await analyticsService.trackEventSecurityAlertDetected( + mockAccount, + mockOrigin, + mockScope, + securityAlertResponse, + securityAlertReason, + securityAlertDescription, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Security Alert Detected', + properties: { + message: 'Snap security alert detected', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + security_alert_response: securityAlertResponse, + security_alert_reason: securityAlertReason, + security_alert_description: securityAlertDescription, + }, + }, + }, + }); + }); + }); + + describe('trackEventSecurityScanCompleted', () => { + it('tracks security scan completed event with alerts detected', async () => { + const scanStatus = ScanStatus.SUCCESS; + const hasSecurityAlerts = true; + + await analyticsService.trackEventSecurityScanCompleted( + mockAccount, + mockOrigin, + mockScope, + scanStatus, + hasSecurityAlerts, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Security Scan Completed', + properties: { + message: 'Snap security scan completed', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + scan_status: scanStatus, + has_security_alerts: hasSecurityAlerts, + }, + }, + }, + }); + }); + + it('tracks security scan completed event without alerts', async () => { + const scanStatus = ScanStatus.SUCCESS; + const hasSecurityAlerts = false; + + await analyticsService.trackEventSecurityScanCompleted( + mockAccount, + mockOrigin, + mockScope, + scanStatus, + hasSecurityAlerts, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'Security Scan Completed', + properties: { + message: 'Snap security scan completed', + origin: mockOrigin, + account_type: mockAccount.type, + chain_id_caip: mockScope, + scan_status: scanStatus, + has_security_alerts: hasSecurityAlerts, + }, + }, + }, + }); + }); + }); + + describe('trackEventWebSocketConnectionClosedNotCleanly', () => { + it('tracks web socket connection closed not cleanly event', async () => { + const mockCode = 1000; + const mockReason = 'mockedReason'; + + await analyticsService.trackEventWebSocketConnectionClosedNotCleanly( + mockOrigin, + mockCode, + mockReason, + ); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: 'WebSocket Connection Closed Not Cleanly', + properties: { + message: 'Snap WebSocket connection closed not cleanly', + origin: mockOrigin, + code: mockCode, + reason: mockReason, + }, + }, + }, + }); + }); + }); + + describe('error handling', () => { + it('does not throw error on snap.request errors', async () => { + const error = new Error('Snap request failed'); + mockSnapRequest.mockRejectedValue(error); + + expect( + await analyticsService.trackEventTransactionAdded( + mockAccount, + mockMetadata, + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.ts b/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.ts new file mode 100644 index 00000000..71b4e6d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/analytics/AnalyticsService.ts @@ -0,0 +1,162 @@ +import type { Transaction } from '@metamask/keyring-api'; +import type { Json } from '@metamask/utils'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { Network, TransactionMetadata } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import logger, { createPrefixedLogger } from '../../utils/logger'; +import type { + ScanStatus, + SecurityAlertResponse, +} from '../transaction-scan/types'; + +/** + * Service for tracking events related to transactions. + */ +export class AnalyticsService { + readonly #logger: ILogger; + + constructor(_logger = logger) { + this.#logger = createPrefixedLogger(_logger, '[📣 AnalyticsService]'); + } + + async #trackEvent( + event: string, + properties: Record, + ): Promise { + try { + this.#logger.log(`Tracking event ${event}`); + + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event, + properties, + }, + }, + }); + } catch (error) { + await trackError(error); + this.#logger.warn('Error tracking event', { + error, + event, + properties, + }); + } + } + + async trackEventTransactionAdded( + account: SolanaKeyringAccount, + metadata: TransactionMetadata, + ): Promise { + await this.#trackEvent('Transaction Added', { + message: 'Snap transaction added', + origin: metadata.origin, + account_type: account.type, + chain_id_caip: metadata.scope, + }); + } + + async trackEventTransactionApproved( + account: SolanaKeyringAccount, + metadata: TransactionMetadata, + ): Promise { + await this.#trackEvent('Transaction Approved', { + message: 'Snap transaction approved', + origin: metadata.origin, + account_type: account.type, + chain_id_caip: metadata.scope, + }); + } + + async trackEventTransactionSubmitted( + account: SolanaKeyringAccount, + signature: string, + metadata: TransactionMetadata, + ): Promise { + await this.#trackEvent('Transaction Submitted', { + message: 'Snap transaction submitted', + origin: metadata.origin, + account_type: account.type, + chain_id_caip: metadata.scope, + }); + } + + async trackEventTransactionFinalized( + account: SolanaKeyringAccount, + transaction: Transaction, + metadata: TransactionMetadata, + ): Promise { + await this.#trackEvent('Transaction Finalized', { + message: 'Snap transaction finalized', + origin: metadata.origin, + account_type: account.type, + chain_id_caip: transaction.chain, + transaction_status: transaction.status, + transaction_type: transaction.type, + }); + } + + async trackEventTransactionRejected( + account: SolanaKeyringAccount, + metadata: TransactionMetadata, + ): Promise { + await this.#trackEvent('Transaction Rejected', { + message: 'Snap transaction rejected', + origin: metadata.origin, + account_type: account.type, + chain_id_caip: metadata.scope, + }); + } + + async trackEventSecurityAlertDetected( + account: SolanaKeyringAccount, + origin: string, + scope: Network, + securityAlertResponse: SecurityAlertResponse, + securityAlertReason: string | null, + securityAlertDescription: string, + ): Promise { + await this.#trackEvent('Security Alert Detected', { + message: 'Snap security alert detected', + origin, + account_type: account.type, + chain_id_caip: scope, + security_alert_response: securityAlertResponse, + security_alert_reason: securityAlertReason, + security_alert_description: securityAlertDescription, + }); + } + + async trackEventSecurityScanCompleted( + account: SolanaKeyringAccount, + origin: string, + scope: Network, + scanStatus: ScanStatus, + hasSecurityAlerts: boolean, + ): Promise { + await this.#trackEvent('Security Scan Completed', { + message: 'Snap security scan completed', + origin, + account_type: account.type, + chain_id_caip: scope, + scan_status: scanStatus, + has_security_alerts: hasSecurityAlerts, + }); + } + + async trackEventWebSocketConnectionClosedNotCleanly( + origin: string, + code: number, + reason: string | null, + ): Promise { + await this.#trackEvent('WebSocket Connection Closed Not Cleanly', { + message: 'Snap WebSocket connection closed not cleanly', + origin, + code, + reason, + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.test.ts new file mode 100644 index 00000000..0a785eb3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.test.ts @@ -0,0 +1,207 @@ +import { COMPUTE_BUDGET_PROGRAM_ADDRESS } from '@solana-program/compute-budget'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable no-restricted-globals */ +import { address as asAddress } from '@solana/kit'; + +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import type { ILogger } from '../../utils/logger'; +import type { TokenHelper } from '../assets/TokenHelper'; +import type { SolanaConnection } from '../connection'; +import { ApproveTokenService } from './ApproveTokenService'; + +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + createKeyPairSignerFromPrivateKeyBytes: jest.fn(), +})); + +jest.mock('../../utils/deriveSolanaKeypair', () => ({ + deriveSolanaKeypair: jest.fn().mockResolvedValue({ + publicKeyBytes: new Uint8Array(32).fill(1), + privateKeyBytes: new Uint8Array(32).fill(2), + }), +})); + +describe('ApproveTokenService', () => { + let service: ApproveTokenService; + let mockConnection: jest.Mocked; + let mockTokenHelper: jest.Mocked; + let mockLogger: jest.Mocked; + + const mockMint = asAddress('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); + const mockDelegate = asAddress( + '4jepDb74FCMr1wgoSA34FeJ2mkvEsJBRZQQRumqp9EL3', + ); + + beforeEach(() => { + jest.clearAllMocks(); + + const { createKeyPairSignerFromPrivateKeyBytes } = require('@solana/kit'); + + (createKeyPairSignerFromPrivateKeyBytes as jest.Mock).mockResolvedValue({ + address: asAddress('7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'), + signMessages: jest.fn(), + signTransactions: jest.fn(), + }); + + mockConnection = { + fetchMint: jest.fn(), + getLatestBlockhash: jest.fn(), + fetchJsonParsedAccount: jest.fn(), + } as unknown as jest.Mocked; + + mockTokenHelper = { + uiAmountToAmountForMint: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger = { + log: jest.fn(), + } as unknown as jest.Mocked; + + service = new ApproveTokenService( + mockConnection, + mockTokenHelper, + mockLogger, + ); + + mockConnection.fetchMint.mockResolvedValue({ + programAddress: TOKEN_PROGRAM_ADDRESS, + data: { decimals: 6 }, + } as any); + mockConnection.getLatestBlockhash.mockResolvedValue({ + blockhash: '9999999999999999999999999999999999999999999' as any, + lastValidBlockHeight: 100n, + }); + + mockTokenHelper.uiAmountToAmountForMint.mockResolvedValue( + 100500000n as any, + ); + + // Default: ATA does not exist + mockConnection.fetchJsonParsedAccount.mockResolvedValue({ + exists: false, + } as any); + + jest.clearAllMocks(); + }); + + describe('buildApprovalTransactionMessage', () => { + it('builds a valid token approval transaction message', async () => { + const transactionMessage = await service.buildApprovalTransactionMessage({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '100.50', + network: Network.Mainnet, + }); + + expect(mockConnection.fetchMint).toHaveBeenCalledWith( + mockMint, + Network.Mainnet, + ); + expect(mockConnection.getLatestBlockhash).toHaveBeenCalledWith( + Network.Mainnet, + ); + expect(mockTokenHelper.uiAmountToAmountForMint).toHaveBeenCalledWith( + mockMint, + Network.Mainnet, + '100.50', + ); + + // Verify the transaction message structure + expect(transactionMessage).toHaveProperty('version', 0); + expect(transactionMessage).toHaveProperty('instructions'); + expect(transactionMessage.instructions).toHaveLength(4); // SetComputeUnitPrice + Create ATA + Approve + SetComputeUnitLimit + }); + + it('builds instructions in the correct order: SetComputeUnitPrice, Create, Approve, SetComputeUnitLimit', async () => { + const SET_COMPUTE_UNIT_PRICE_DISCRIMINANT = 3; + const SET_COMPUTE_UNIT_LIMIT_DISCRIMINANT = 2; + + const { instructions } = await service.buildApprovalTransactionMessage({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '100.50', + network: Network.Mainnet, + }); + + expect(instructions).toHaveLength(4); + // First: SetComputeUnitPrice + expect(instructions[0].programAddress).toBe( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ); + expect(instructions[0].data?.[0]).toBe( + SET_COMPUTE_UNIT_PRICE_DISCRIMINANT, + ); + // Second: Create ATA (token program) + expect(instructions[1]?.programAddress).not.toBe( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ); + // Third: Approve (token program) + expect(instructions[2]?.programAddress).not.toBe( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ); + // Last: SetComputeUnitLimit + expect(instructions[3]?.programAddress).toBe( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ); + expect(instructions[3]?.data?.[0]).toBe( + SET_COMPUTE_UNIT_LIMIT_DISCRIMINANT, + ); + }); + + it('omits the Create ATA instruction when the ATA already exists', async () => { + mockConnection.fetchJsonParsedAccount.mockResolvedValue({ + exists: true, + address: asAddress('11111111111111111111111111111111'), + data: {}, + programAddress: TOKEN_PROGRAM_ADDRESS, + } as any); + + const transactionMessage = await service.buildApprovalTransactionMessage({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '100.50', + network: Network.Mainnet, + }); + + expect(transactionMessage).toHaveProperty('version', 0); + expect(transactionMessage).toHaveProperty('instructions'); + expect(transactionMessage.instructions).toHaveLength(3); // SetComputeUnitPrice + Approve + SetComputeUnitLimit (no Create ATA) + }); + + it('fetches mint info to get token program', async () => { + await service.buildApprovalTransactionMessage({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '50', + network: Network.Devnet, + }); + + expect(mockConnection.fetchMint).toHaveBeenCalledWith( + mockMint, + Network.Devnet, + ); + }); + + it('converts UI amount to raw token amount', async () => { + await service.buildApprovalTransactionMessage({ + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + mint: mockMint, + delegate: mockDelegate, + amount: '250.75', + network: Network.Mainnet, + }); + + expect(mockTokenHelper.uiAmountToAmountForMint).toHaveBeenCalledWith( + mockMint, + Network.Mainnet, + '250.75', + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.ts b/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.ts new file mode 100644 index 00000000..fd2dd6fb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/approve-token/ApproveTokenService.ts @@ -0,0 +1,197 @@ +import { + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, +} from '@solana-program/compute-budget'; +import { + findAssociatedTokenPda, + getApproveInstruction, + getCreateAssociatedTokenInstruction, +} from '@solana-program/token'; +import { + getApproveInstruction as getApproveInstruction2022, + getCreateAssociatedTokenInstruction as getCreateAssociatedTokenInstruction2022, + TOKEN_2022_PROGRAM_ADDRESS, +} from '@solana-program/token-2022'; +import type { + Address, + CompilableTransactionMessage, + IInstruction, +} from '@solana/kit'; +import { + appendTransactionMessageInstructions, + assertAccountExists, + createKeyPairSignerFromPrivateKeyBytes, + createTransactionMessage, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, +} from '@solana/kit'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { Network } from '../../constants/solana'; +import { deriveSolanaKeypair } from '../../utils/deriveSolanaKeypair'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { TokenHelper } from '../assets/TokenHelper'; +import type { SolanaConnection } from '../connection'; + +export type ApproveTokenParams = { + account: SolanaKeyringAccount; + mint: Address; + delegate: Address; + amount: string; + network: Network; +}; + +/** + * Service for building SPL token approval transactions. + * This allows a delegate to spend tokens on behalf of the token owner. + */ +export class ApproveTokenService { + readonly #connection: SolanaConnection; + + readonly #tokenHelper: TokenHelper; + + readonly #logger: ILogger; + + /** + * The transaction built here consumes up to ~10,000 compute units when approving + * an existing associated token account, but requires ~30,000+ compute units when + * creating the user's associated token account. Same as `SendSplTokenBuilder`. + */ + readonly #computeUnitLimit = 40_000; + + readonly #computeUnitPriceMicroLamportsPerComputeUnit = 10000n; + + constructor( + connection: SolanaConnection, + tokenHelper: TokenHelper, + logger: ILogger, + ) { + this.#connection = connection; + this.#tokenHelper = tokenHelper; + this.#logger = createPrefixedLogger(logger, '[🔐 ApproveTokenService]'); + } + + /** + * Builds a token approval transaction message. + * + * This creates an SPL token `approve` instruction that allows the delegate + * to spend tokens from the user's associated token account up to the + * specified amount. + * + * @param params - The parameters for building the approval transaction. + * @returns A promise that resolves to the compilable transaction message. + */ + async buildApprovalTransactionMessage( + params: ApproveTokenParams, + ): Promise { + this.#logger.log('Building token approval transaction', { + mint: params.mint, + delegate: params.delegate, + amount: params.amount, + network: params.network, + }); + + const { + account: { entropySource, derivationPath }, + mint, + delegate, + amount, + network, + } = params; + + // Fetch mint info to get the token program + const [mintAccount, latestBlockhash, { privateKeyBytes }] = + await Promise.all([ + this.#connection.fetchMint(mint, network), + this.#connection.getLatestBlockhash(network), + deriveSolanaKeypair({ entropySource, derivationPath }), + ]); + + const { programAddress: tokenProgram } = mintAccount; + + const signer = + await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); + + // Derive the user's Associated Token Account (ATA) address + const ownerATA = ( + await findAssociatedTokenPda({ + mint, + owner: signer.address, + tokenProgram, + }) + )[0]; + + // Convert UI amount to raw token amount and check ATA existence in parallel + const [rawAmount, ownerAtaAccount] = await Promise.all([ + this.#tokenHelper.uiAmountToAmountForMint(mint, network, amount), + this.#connection.fetchJsonParsedAccount(ownerATA, network, undefined, { + skipCache: true, + }), + ]); + + let ataExists = false; + try { + assertAccountExists(ownerAtaAccount); + ataExists = true; + } catch { + // ATA doesn't exist yet, it will be created below + } + + const isToken2022 = tokenProgram === TOKEN_2022_PROGRAM_ADDRESS; + + const instructions: IInstruction[] = []; + + // Only create the ATA if it doesn't already exist. + // Uses `Create` (not `CreateIdempotent`) because the card partner + // only detects the standard Create instruction. + if (!ataExists) { + const getCreateAtaInstructionFn = isToken2022 + ? getCreateAssociatedTokenInstruction2022 + : getCreateAssociatedTokenInstruction; + + instructions.push( + getCreateAtaInstructionFn({ + ata: ownerATA, + mint, + owner: signer.address, + payer: signer, + tokenProgram, + }), + ); + } + + // TODO: When Baanx correctly indexes it, this should be switched back + // to `getApproveCheckedInstruction2022` and `getApproveCheckedInstruction` + const getApproveInstructionFn = isToken2022 + ? getApproveInstruction2022 + : getApproveInstruction; + + instructions.push( + getApproveInstructionFn({ + source: ownerATA, + delegate, + owner: signer, + amount: rawAmount, + }), + ); + + // Build the transaction message with instructions in order: + // SetComputeUnitPrice, Create (if needed), Approve, SetComputeUnitLimit + const allInstructions: IInstruction[] = [ + getSetComputeUnitPriceInstruction({ + microLamports: this.#computeUnitPriceMicroLamportsPerComputeUnit, + }), + ...instructions, + getSetComputeUnitLimitInstruction({ units: this.#computeUnitLimit }), + ]; + + return pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayer(signer.address, tx), + (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + (tx) => appendTransactionMessageInstructions(allInstructions, tx), + ); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/approve-token/index.ts b/merged-packages/solana-wallet-snap/src/core/services/approve-token/index.ts new file mode 100644 index 00000000..ac7085ef --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/approve-token/index.ts @@ -0,0 +1,4 @@ +export { + ApproveTokenService, + type ApproveTokenParams, +} from './ApproveTokenService'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts new file mode 100644 index 00000000..0c74ce76 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts @@ -0,0 +1,264 @@ +import { cloneDeep } from 'lodash'; + +import { + MOCK_ASSET_ENTITIES, + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, +} from '../../test/mocks/asset-entities'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import { InMemoryState } from '../state/InMemoryState'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; +import { AssetsRepository } from './AssetsRepository'; + +describe('AssetsRepository', () => { + let repository: AssetsRepository; + let mockState: IStateManager; + + beforeEach(() => { + mockState = new InMemoryState(cloneDeep(DEFAULT_UNENCRYPTED_STATE)); + repository = new AssetsRepository(mockState); + }); + + describe('findByKeyringAccountId', () => { + it('returns empty array when no assets exist for the account', async () => { + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([]); + }); + + it('returns assets for the specified account', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + }); + + it('returns assets only for the requested account', async () => { + const assetForAccount1 = { + ...MOCK_ASSET_ENTITY_0, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + }; + + await repository.saveMany([ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + assetForAccount1, + ]); + + const assetsForAccount0 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + const assetsForAccount1 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + ); + + expect(assetsForAccount0).toStrictEqual([ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + ]); + expect(assetsForAccount1).toStrictEqual([assetForAccount1]); + }); + }); + + describe('getAll', () => { + it('returns empty array when no assets exist', async () => { + const assets = await repository.getAll(); + + expect(assets).toStrictEqual([]); + }); + + it('returns all assets from all accounts', async () => { + const assetForAccount1 = { + ...MOCK_ASSET_ENTITY_0, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + }; + + await repository.saveMany([ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + assetForAccount1, + ]); + + const assets = await repository.getAll(); + + expect(assets).toStrictEqual([ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + assetForAccount1, + ]); + }); + }); + + describe('saveMany', () => { + it('saves a single asset', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_0]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + + it('saves multiple assets', async () => { + await repository.saveMany(MOCK_ASSET_ENTITIES); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + + it('saves assets for multiple accounts', async () => { + const assetForAccount1 = { + ...MOCK_ASSET_ENTITY_0, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + }; + + await repository.saveMany([MOCK_ASSET_ENTITY_0, assetForAccount1]); + + const assetsForAccount0 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + const assetsForAccount1 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + ); + + expect(assetsForAccount0).toStrictEqual([MOCK_ASSET_ENTITY_0]); + expect(assetsForAccount1).toStrictEqual([assetForAccount1]); + }); + + it('overrides existing assets with the same assetType and keyringAccountId', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_1]); + + const updatedAsset = { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '999999999', + uiAmount: '999.999999', + }; + + await repository.saveMany([updatedAsset]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([updatedAsset]); + }); + + it('adds new asset when assetType differs', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_0]); + + await repository.saveMany([MOCK_ASSET_ENTITY_1]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + }); + + it('maintains existing assets when adding new ones', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + + await repository.saveMany([MOCK_ASSET_ENTITY_2]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, + ]); + }); + + it('updates state atomically for multiple assets', async () => { + const updatedAsset0 = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '777777777', + uiAmount: '0.777777777', + }; + const updatedAsset1 = { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '888888888', + uiAmount: '888.888888', + }; + + await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + + await repository.saveMany([updatedAsset0, updatedAsset1]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual([updatedAsset0, updatedAsset1]); + }); + + it('handles empty array input', async () => { + await repository.saveMany([]); + + const assets = await repository.getAll(); + + expect(assets).toStrictEqual([]); + }); + + it('preserves assets from different accounts when updating', async () => { + const assetForAccount1 = { + ...MOCK_ASSET_ENTITY_0, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + }; + + await repository.saveMany([MOCK_ASSET_ENTITY_0, assetForAccount1]); + + const updatedAsset = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '555555555', + uiAmount: '0.555555555', + }; + + await repository.saveMany([updatedAsset]); + + const assetsForAccount0 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + const assetsForAccount1 = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + ); + + expect(assetsForAccount0).toStrictEqual([updatedAsset]); + expect(assetsForAccount1).toStrictEqual([assetForAccount1]); + }); + + it('handles duplicate assets in the same save operation', async () => { + const duplicateAsset = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '123456789', + uiAmount: '0.123456789', + }; + + await repository.saveMany([MOCK_ASSET_ENTITY_0, duplicateAsset]); + + const assets = await repository.findByKeyringAccountId( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + // The last occurrence should win + expect(assets).toStrictEqual([duplicateAsset]); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts new file mode 100644 index 00000000..3ed632cc --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts @@ -0,0 +1,61 @@ +import { cloneDeep } from 'lodash'; + +import type { AssetEntity } from '../../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AssetsRepository { + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + async findByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const assets = await this.#state.getKey( + `assetEntities.${keyringAccountId}`, + ); + + return assets ?? []; + } + + async getAll(): Promise { + const assetsByAccount = + (await this.#state.getKey( + 'assetEntities', + )) ?? {}; + + return Object.values(assetsByAccount).flat(); + } + + async saveMany(assets: AssetEntity[]): Promise { + // Update the state atomically + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); + for (const asset of assets) { + const { keyringAccountId } = asset; + const accountAssets = cloneDeep( + newState.assetEntities[keyringAccountId] ?? [], + ); + + // Avoid duplicates. If same asset is already saved, override it. + const existingAssetIndex = accountAssets.findIndex( + (item) => + item.assetType === asset.assetType && + item.keyringAccountId === asset.keyringAccountId, + ); + + if (existingAssetIndex === -1) { + accountAssets.push(asset); + } else { + accountAssets[existingAssetIndex] = asset; + } + + newState.assetEntities[keyringAccountId] = accountAssets; + } + return newState; + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts new file mode 100644 index 00000000..d7e7f672 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -0,0 +1,607 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { cloneDeep } from 'lodash'; + +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; +import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; +import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; +import { Network } from '../../constants/solana'; +import type { Serializable } from '../../serialization/types'; +import { + MOCK_ASSET_ENTITIES, + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, + SOLANA_MOCK_TOKEN_METADATA, +} from '../../test/mocks/asset-entities'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import type { ConfigProvider } from '../config'; +import type { SolanaConnection } from '../connection'; +import { mockLogger } from '../mocks/logger'; +import { createMockConnection } from '../mocks/mockConnection'; +import { MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE } from '../mocks/mockSolanaRpcResponses'; +import type { TokenPricesService } from '../token-prices/TokenPrices'; +import type { AssetsRepository } from './AssetsRepository'; +import { AssetsService } from './AssetsService'; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +describe('AssetsService', () => { + let assetsService: AssetsService; + let mockConnection: SolanaConnection; + let mockConfigProvider: ConfigProvider; + let mockAssetsRepository: AssetsRepository; + let mockTokenApiClient: TokenApiClient; + let mockTokenPricesService: TokenPricesService; + let mockNftApiClient: NftApiClient; + let mockCache: ICache; + + beforeEach(() => { + jest.clearAllMocks(); + mockConnection = createMockConnection(); + + mockConfigProvider = { + getActiveNetworks: jest.fn().mockResolvedValue([Network.Mainnet]), + } as unknown as ConfigProvider; + + mockTokenApiClient = { + getTokensMetadata: jest + .fn() + .mockResolvedValue(SOLANA_MOCK_TOKEN_METADATA), + } as unknown as TokenApiClient; + + mockTokenPricesService = { + getMultipleTokenConversions: jest.fn().mockResolvedValue({}), + getMultipleTokensMarketData: jest.fn().mockResolvedValue({}), + getHistoricalPrice: jest + .fn() + .mockResolvedValue({ intervals: {}, updateTime: 0, expirationTime: 0 }), + } as unknown as TokenPricesService; + + mockCache = new InMemoryCache(mockLogger); + + mockNftApiClient = { + listAddressSolanaNfts: jest + .fn() + .mockResolvedValue(MOCK_NFTS_LIST_RESPONSE_MAPPED.items), + } as unknown as NftApiClient; + + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + + mockAssetsRepository = { + findByKeyringAccountId: jest.fn(), + getAll: jest.fn(), + saveMany: jest.fn(), + } as unknown as AssetsRepository; + + assetsService = new AssetsService({ + connection: mockConnection, + logger: mockLogger, + configProvider: mockConfigProvider, + assetsRepository: mockAssetsRepository, + tokenApiClient: mockTokenApiClient, + tokenPricesService: mockTokenPricesService, + cache: mockCache, + nftApiClient: mockNftApiClient, + }); + }); + + describe('fetch', () => { + it('fetches native and token assets', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getBalance: jest.fn().mockReturnValueOnce({ + send: jest.fn().mockResolvedValue({ + value: 1000000000, // Native balance on Mainnet + }), + }), + getTokenAccountsByOwner: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockResolvedValueOnce({ + value: + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result + .value, + }) + .mockResolvedValue({ + value: [], + }), + }), + } as any); + + const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + + it('does not fail on individual RPC call failures to fetch native assets', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getBalance: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockRejectedValueOnce(new Error('Error getting balance')), + }), + getTokenAccountsByOwner: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockResolvedValueOnce({ + value: + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result + .value, + }) + .mockResolvedValue({ + value: [], + }), + }), + } as any); + + const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]); + }); + + it('does not fail on individual RPC call failures to fetch token assets', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getBalance: jest.fn().mockReturnValueOnce({ + send: jest.fn().mockResolvedValue({ + value: 1000000000, // Native balance on Mainnet + }), + }), + getTokenAccountsByOwner: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockRejectedValueOnce(new Error('Error getting token accounts')), + }), + } as any); + + const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + }); + + describe('save', () => { + it('saves an asset', async () => { + const spy = jest + .spyOn(assetsService, 'saveMany') + .mockResolvedValueOnce(undefined); + + await assetsService.save(MOCK_ASSET_ENTITY_0); + + expect(spy).toHaveBeenCalledWith([MOCK_ASSET_ENTITY_0]); + }); + }); + + describe('saveMany', () => { + it('delegates to repository for saving assets', async () => { + const saveManySpy = jest + .spyOn(mockAssetsRepository, 'saveMany') + .mockResolvedValue(undefined); + + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); + + await assetsService.saveMany(MOCK_ASSET_ENTITIES); + + expect(saveManySpy).toHaveBeenCalledWith(MOCK_ASSET_ENTITIES); + }); + + it('emits event "AccountAssetListUpdated" with ALL assets in added list, and removed assets in removed list', async () => { + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); + + const addedAssets = [MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]; + const removedAssets = [ + { + ...MOCK_ASSET_ENTITY_2, + rawAmount: '0', + }, + ]; + + await assetsService.saveMany([...addedAssets, ...removedAssets]); + + expect(emitSnapKeyringEvent).toHaveBeenNthCalledWith( + 1, + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [ + MOCK_ASSET_ENTITY_0.assetType, + MOCK_ASSET_ENTITY_1.assetType, + ], + removed: [MOCK_ASSET_ENTITY_2.assetType], + }, + }, + }, + ); + }); + + it('emits event "AccountAssetListUpdated" when an asset was saved with a zero balance and some more is added', async () => { + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValueOnce([{ ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }]) + .mockResolvedValueOnce([{ ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }]); + + await assetsService.saveMany([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, + ]); + + await assetsService.saveMany([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '1000000' }, + ]); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [MOCK_ASSET_ENTITY_0.assetType], + removed: [], + }, + }, + }, + ); + }); + + it('emits event "AccountBalancesUpdated" when balances change', async () => { + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); + + await assetsService.saveMany(MOCK_ASSET_ENTITIES); + + expect(emitSnapKeyringEvent).toHaveBeenNthCalledWith( + 2, + snap, + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [MOCK_ASSET_ENTITY_0.assetType]: { + unit: MOCK_ASSET_ENTITY_0.symbol, + amount: MOCK_ASSET_ENTITY_0.uiAmount, + }, + [MOCK_ASSET_ENTITY_1.assetType]: { + unit: MOCK_ASSET_ENTITY_1.symbol, + amount: MOCK_ASSET_ENTITY_1.uiAmount, + }, + [MOCK_ASSET_ENTITY_2.assetType]: { + unit: MOCK_ASSET_ENTITY_2.symbol, + amount: MOCK_ASSET_ENTITY_2.uiAmount, + }, + }, + }, + }, + ); + }); + + it('emits event "AccountBalancesUpdated" when native balance goes from non-zero to zero', async () => { + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValue([{ ...MOCK_ASSET_ENTITY_0, uiAmount: '1234' }]); + + await assetsService.saveMany([{ ...MOCK_ASSET_ENTITY_0, uiAmount: '0' }]); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [MOCK_ASSET_ENTITY_0.assetType]: { + unit: MOCK_ASSET_ENTITY_0.symbol, + amount: '0', + }, + }, + }, + }, + ); + }); + + // With isIncremental = false, we do emit events, even when no assets changed + it.skip('does not emit events when no assets changed', async () => { + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValue(MOCK_ASSET_ENTITIES); + + await assetsService.saveMany(MOCK_ASSET_ENTITIES); + (emitSnapKeyringEvent as jest.Mock).mockClear(); + + await assetsService.saveMany(MOCK_ASSET_ENTITIES); + + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }); + + it('fetches saved assets before saving new assets to ensure correct change detection', async () => { + const callOrder: string[] = []; + + const getAllSpy = jest + .spyOn(mockAssetsRepository, 'getAll') + .mockImplementation(async () => { + callOrder.push('getAll'); + return []; + }); + + const saveManySpy = jest + .spyOn(mockAssetsRepository, 'saveMany') + .mockImplementation(async () => { + callOrder.push('saveMany'); + }); + + await assetsService.saveMany(MOCK_ASSET_ENTITIES); + + // Verify that getAll was called before saveMany + expect(callOrder).toStrictEqual(['getAll', 'saveMany']); + expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(saveManySpy).toHaveBeenCalledTimes(1); + }); + + it('correctly detects new assets when savedAssets is fetched before saving', async () => { + // Start with empty state + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); + + await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); + + // Should emit AccountAssetListUpdated with the new asset + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [MOCK_ASSET_ENTITY_0.assetType], + removed: [], + }, + }, + }, + ); + }); + + it('correctly detects assets going from zero to non-zero balance when savedAssets is fetched before saving', async () => { + const assetWithZeroBalance = { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }; + const assetWithNonZeroBalance = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '1000000', + }; + + // First save with zero balance + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); + await assetsService.saveMany([assetWithZeroBalance]); + + (emitSnapKeyringEvent as jest.Mock).mockClear(); + + // Then save with non-zero balance, but getAll should return the state before this save + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValueOnce([assetWithZeroBalance]); + await assetsService.saveMany([assetWithNonZeroBalance]); + + // Should emit AccountAssetListUpdated because asset went from zero to non-zero + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [MOCK_ASSET_ENTITY_0.assetType], + removed: [], + }, + }, + }, + ); + }); + + // With isIncremental = false, we do emit events, even when no assets changed + it.skip('does not incorrectly mark assets as new when they are already in the saved state', async () => { + // Mock that the asset already exists in saved state + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); + + await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); + + // Should not emit AccountAssetListUpdated since no assets were actually added/removed + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + expect.any(Object), + ); + }); + + it('correctly identifies balance changes when savedAssets reflects pre-save state', async () => { + const originalAsset = { ...MOCK_ASSET_ENTITY_0, rawAmount: '1000000' }; + const updatedAsset = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '2000000', + uiAmount: '2.0', + }; + + // Mock that original asset exists in saved state + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValueOnce([originalAsset]); + + await assetsService.saveMany([updatedAsset]); + + // Should emit AccountBalancesUpdated because balance changed + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [MOCK_ASSET_ENTITY_0.assetType]: { + unit: updatedAsset.symbol, + amount: updatedAsset.uiAmount, + }, + }, + }, + }, + ); + }); + + it('does not include native assets in removed array even when they have zero balance', async () => { + const nativeAssetWithZeroBalance = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '0', + }; + const tokenAssetWithZeroBalance = { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '0', + }; + + // Mock that both assets existed with non-zero balance + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([ + MOCK_ASSET_ENTITY_0, // Native asset with non-zero balance + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + ]); + + await assetsService.saveMany([ + nativeAssetWithZeroBalance, + tokenAssetWithZeroBalance, + ]); + + // Should emit AccountAssetListUpdated with only the token asset in the removed array + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [MOCK_ASSET_ENTITY_0.assetType], + removed: [MOCK_ASSET_ENTITY_1.assetType], // Only token asset, not native + }, + }, + }, + ); + }); + }); + + describe('hasChanged', () => { + it('returns true if the raw amount has changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + asset.rawAmount = '123'; + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(AssetsService.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns true if the ui amount has changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + asset.uiAmount = '123'; + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(AssetsService.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns true if the asset does not exist in the lookup', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + const assetsLookup = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; + + expect(AssetsService.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns false if the asset has not changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(AssetsService.hasChanged(asset, assetsLookup)).toBe(false); + }); + }); + + describe('getAll', () => { + it('delegates to repository and returns all assets', async () => { + jest + .spyOn(mockAssetsRepository, 'getAll') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAll(); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + }); + + describe('findByAccount', () => { + it('returns saved assets for the account when they exist', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.findByAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + + it('includes placeholder native assets when no assets exist', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); + + const assets = await assetsService.findByAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(assets).toStrictEqual([ + { + assetType: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }, + ]); + }); + + it('includes placeholder native assets with zero balance when no native assets exist', async () => { + const nonNativeAssets = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; // Token assets only + + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(nonNativeAssets); + + const assets = await assetsService.findByAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + // Should include the saved assets plus a placeholder native asset + expect(assets).toHaveLength(nonNativeAssets.length + 1); + expect(assets).toStrictEqual( + expect.arrayContaining([ + ...nonNativeAssets, + { + assetType: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }, + ]), + ); + }); + + it('does not add placeholder native assets when they already exist', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); // Includes native asset (MOCK_ASSET_ENTITY_0) + + const assets = await assetsService.findByAccount( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts new file mode 100644 index 00000000..a2778e7b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -0,0 +1,678 @@ +/* eslint-disable jsdoc/require-returns */ + +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, + Balance, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { + FungibleAssetMarketData, + FungibleAssetMetadata, +} from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; +import { Duration, parseCaipAssetType } from '@metamask/utils'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import type { + AccountInfoBase, + AccountInfoWithPubkey, + Address, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { + AssetEntity, + NativeAsset, + SolanaKeyringAccount, + TokenAsset, +} from '../../../entities'; +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; +import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; +import type { + Caip10Address, + NativeCaipAssetType, + NftCaipAssetType, + TokenCaipAssetType, +} from '../../constants/solana'; +import { Network, SolanaCaip19Tokens } from '../../constants/solana'; +import type { TokenAccountInfoWithJsonData } from '../../sdk-extensions/rpc-api'; +import type { Serializable } from '../../serialization/types'; +import { fromTokenUnits } from '../../utils/fromTokenUnit'; +import { getNetworkFromToken } from '../../utils/getNetworkFromToken'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { ConfigProvider } from '../config'; +import type { SolanaConnection } from '../connection'; +import type { TokenPricesService } from '../token-prices/TokenPrices'; +import type { AssetsRepository } from './AssetsRepository'; +import type { AssetMetadata, NonFungibleAssetMetadata } from './types'; + +/** + * Extends a token account as returned by the `getTokenAccountsByOwner` RPC method with the scope and the caip-19 asset type for convenience. + */ +type TokenAccountWithMetadata = { + token: AccountInfoWithPubkey; + scope: Network; + assetType: TokenCaipAssetType; + keyringAccount: SolanaKeyringAccount; +} & Serializable; + +export class AssetsService { + readonly #logger: ILogger; + + readonly #connection: SolanaConnection; + + readonly #configProvider: ConfigProvider; + + readonly #assetsRepository: AssetsRepository; + + readonly #tokenPricesService: TokenPricesService; + + readonly #tokenApiClient: TokenApiClient; + + readonly #cache: ICache; + + readonly #nftApiClient: NftApiClient; + + public static readonly cacheTtlsMilliseconds = { + tokenAccountsByOwner: 5 * Duration.Second, + }; + + constructor({ + connection, + logger, + configProvider, + assetsRepository, + tokenApiClient, + tokenPricesService, + cache, + nftApiClient, + }: { + connection: SolanaConnection; + logger: ILogger; + configProvider: ConfigProvider; + assetsRepository: AssetsRepository; + tokenApiClient: TokenApiClient; + tokenPricesService: TokenPricesService; + cache: ICache; + nftApiClient: NftApiClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); + this.#connection = connection; + this.#configProvider = configProvider; + this.#assetsRepository = assetsRepository; + this.#tokenApiClient = tokenApiClient; + this.#tokenPricesService = tokenPricesService; + this.#cache = cache; + this.#nftApiClient = nftApiClient; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]) { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith(SolanaCaip19Tokens.SOL), + ) as NativeCaipAssetType[]; + const tokenAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/token:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/nft:'), + ) as NftCaipAssetType[]; + + return { nativeAssetTypes, tokenAssetTypes, nftAssetTypes }; + } + + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const { + chain: { namespace, reference }, + assetNamespace, + assetReference, + } = parseCaipAssetType(assetType); + + nativeTokensMetadata[assetType] = { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/${namespace}/${reference}/${assetNamespace}/${assetReference}.png`, + units: [ + { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + async #getNftsMetadata( + assetTypes: NftCaipAssetType[], + ): Promise> { + const nftsMetadata = await this.#nftApiClient.getNftsMetadata( + assetTypes.map((assetType) => { + const { assetReference } = parseCaipAssetType(assetType); + return assetReference; + }), + ); + + const nftsMetadataMap: Record = + {}; + + assetTypes.forEach((assetType, index) => { + const nftMetadata = nftsMetadata[index]; + + if (!nftMetadata) { + return; + } + + const metadata = { + name: nftMetadata.name, + symbol: nftMetadata.name, + imageUrl: nftMetadata.imageUrl, + description: nftMetadata.description, + fungible: false as const, + isPossibleSpam: false, // FIXME: The isSpam should be part of the NFT item response, not balance, otherwise we can't get it here + attributes: Object.fromEntries( + nftMetadata.attributes.map( + (attr: { key: string; value: string | number }) => [ + attr.key, + attr.value, + ], + ), + ), + collection: { + name: nftMetadata.collectionName, + address: nftMetadata.onchainCollectionAddress as Caip10Address, + symbol: nftMetadata.collectionSymbol, + tokenCount: nftMetadata.collectionCount, + creator: '' as Caip10Address, // FIXME: There can be more than one creator + imageUrl: nftMetadata.collectionImageUrl ?? '', + }, + }; + + nftsMetadataMap[assetType] = metadata; + }); + + return nftsMetadataMap; + } + + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.log('Fetching metadata for assets', assetTypes); + + const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = + this.#splitAssetsByType(assetTypes); + + const [ + nativeTokensMetadata, + tokensMetadata, + // nftMetadata, + ] = await Promise.all([ + this.#getNativeTokensMetadata(nativeAssetTypes), + this.#tokenApiClient.getTokensMetadata(tokenAssetTypes), + // this.#getNftsMetadata(nftAssetTypes), + ]); + + return { + ...nativeTokensMetadata, + ...tokensMetadata, + // ...nftMetadata, + }; + } + + /** + * Matrix-fetches all token accounts owned by the given address on the specified networks and program ids, + * and merges the results into a single array. Each individual token is augmented with the scope and the caip-19 asset type for convenience. + * + * It caches the results for each pair of scope and program id. + * + * @param accounts - The owners of the token accounts. + * @param programIds - The program ids to fetch the token accounts for. + * @param scopes - The networks to fetch the token accounts for. + * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. + */ + async #fetchTokenAccountsMultiple( + accounts: SolanaKeyringAccount[], + programIds: Address[] = [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], + scopes: Network[] = [Network.Mainnet], + ): Promise { + if (programIds.length === 0 || scopes.length === 0) { + return []; + } + + // Create all combinations of account, programId, and scope + const combinations = accounts.flatMap((account) => + programIds.flatMap((programId) => + scopes.map((scope) => ({ account, programId, scope })), + ), + ); + + const fetchTokenAccountsCached = useCache< + [SolanaKeyringAccount, Address, Network], + TokenAccountWithMetadata[] + >(this.#fetchTokenAccounts.bind(this), this.#cache, { + functionName: 'AssetsService:fetchTokenAccounts', + ttlMilliseconds: AssetsService.cacheTtlsMilliseconds.tokenAccountsByOwner, + generateCacheKey: (functionName, args) => { + const [account, programId, scope] = args; + return `${functionName}:${account.id}:${programId}:${scope}`; + }, + }); + + const responses = await Promise.allSettled( + combinations.map(async ({ account, programId, scope }) => { + const response = await fetchTokenAccountsCached( + account, + programId, + scope, + ); + return response; + }), + ); + + return responses.flatMap((item) => + item.status === 'fulfilled' ? item.value : [], + ); + } + + /** + * Fetches the token accounts for the given owner and program id on the specified scope. + * + * @param account - The owner of the token accounts. + * @param programId - The program id to fetch the token accounts for. + * @param scope - The scope to fetch the token accounts for. + * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. + */ + async #fetchTokenAccounts( + account: SolanaKeyringAccount, + programId: Address = TOKEN_PROGRAM_ADDRESS, + scope: Network = Network.Mainnet, + ): Promise { + const response = await this.#connection + .getRpc(scope) + .getTokenAccountsByOwner( + asAddress(account.address), + { programId }, + { encoding: 'jsonParsed' }, + ) + .send(); + + const tokens = response.value; + + // Attach the scope and the caip-19 asset type to each token account for easier future reference + return tokens.map( + (token) => + ({ + token, + scope, + assetType: tokenAddressToCaip19( + scope, + token.account.data.parsed.info.mint, + ), + keyringAccount: account, + }) as TokenAccountWithMetadata, + ); + } + + /** + * Fetches all assets for the given account. + * + * @param account - The account to get the balances for. + * @returns The balances and metadata of the account for the given assets. + */ + async fetch(account: SolanaKeyringAccount): Promise { + const [nativeAssets, tokenAccounts] = await Promise.all([ + this.#fetchNativeAssets(account), + this.#fetchTokenAccountsMultiple( + [account], + [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], + await this.#configProvider.getActiveNetworks(), + ), + ]); + + const assetTypes = tokenAccounts.map( + (tokenAccount) => tokenAccount.assetType, + ); + + const tokensMetadata = + await this.#tokenApiClient.getTokensMetadata(assetTypes); + + const tokenAssets: TokenAsset[] = tokenAccounts + .filter((tokenAccount) => tokenAccount.assetType.includes('/token:')) + .map((tokenAccount) => { + const { assetType } = tokenAccount; + const { decimals, amount, uiAmountString } = + tokenAccount.token.account.data.parsed.info.tokenAmount; + + return { + assetType, + keyringAccountId: tokenAccount.keyringAccount.id, + network: tokenAccount.scope, + mint: tokenAccount.token.account.data.parsed.info.mint, + pubkey: tokenAccount.token.pubkey, + symbol: tokensMetadata[assetType]?.symbol ?? 'UNKNOWN', + decimals, + rawAmount: amount, + uiAmount: uiAmountString ?? fromTokenUnits(amount, decimals), + }; + }); + + // const nftAssets = await this.#fetchNftAssets(account, tokenAccounts.filter( + // (token) => token.assetType.includes('/nft:'), + // )); + + return [ + ...nativeAssets, + ...tokenAssets, + // ...nftAssets, + ]; + } + + async getNativeAssetTypes(): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + return activeNetworks.map( + (network) => `${network}/${SolanaCaip19Tokens.SOL}` as const, + ); + } + + async #fetchNativeAssets( + account: SolanaKeyringAccount, + ): Promise { + const nativeAssetsTypes = await this.getNativeAssetTypes(); + + const accountAddress = asAddress(account.address); + + const balancePromises = nativeAssetsTypes.map(async (assetType) => { + const balance = await this.#connection + .getRpc(getNetworkFromToken(assetType)) + .getBalance(accountAddress) + .send(); + + return { + assetType, + keyringAccountId: account.id, + network: getNetworkFromToken(assetType), + address: accountAddress, + symbol: 'SOL', + decimals: 9, + rawAmount: balance.value.toString(), + uiAmount: fromTokenUnits(balance.value, 9), + }; + }); + + const results = (await Promise.allSettled(balancePromises)).flatMap( + (item) => (item.status === 'fulfilled' ? item.value : []), + ); + + return results; + } + + async #fetchNftAssets( + account: SolanaKeyringAccount, + assetIds: NftCaipAssetType[], + ): Promise> { + const accountAddress = asAddress(account.address); + + const nftAssets = + await this.#nftApiClient.listAddressSolanaNfts(accountAddress); + const balances: Record = {}; + + for (const assetId of assetIds) { + const { assetReference } = parseCaipAssetType(assetId); + + const nftAsset = nftAssets.find( + (nft) => nft.tokenAddress === assetReference, + ); + + if (!nftAsset) { + continue; + } + + balances[assetId] = { + unit: nftAsset.nftToken.name, + amount: nftAsset.balance.toString(), + }; + } + + return balances; + } + + async fetchAssetsMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + this.#logger.info('Fetching market data for assets', assets); + + const marketData = + await this.#tokenPricesService.getMultipleTokensMarketData(assets); + return marketData; + } + + async save(asset: AssetEntity): Promise { + await this.saveMany([asset]); + } + + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + /** + * Should we save the assets incrementally? + * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. + * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. + */ + const isIncremental = false; + + const hasZeroAmount = (asset: AssetEntity) => + asset.rawAmount === '0' || asset.uiAmount === '0'; + + const hasNonZeroAmount = (asset: AssetEntity) => !hasZeroAmount(asset); + + const savedAssets = await this.getAll(); + + // Save assets using repository + await this.#assetsRepository.saveMany(assets); + + // Notify the extension about the new assets in a single event + const isNew = (asset: AssetEntity) => + !savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + const wasSavedWithZeroAmount = (asset: AssetEntity) => { + const savedAsset = savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + return savedAsset && hasZeroAmount(savedAsset); + }; + + const isNativeAsset = (asset: AssetEntity) => + asset.assetType.includes(SolanaCaip19Tokens.SOL); + + const shouldBeInRemovedList = (asset: AssetEntity) => + hasZeroAmount(asset) && !isNativeAsset(asset); // Never remove native assets from the account asset list + + const shouldBeInAddedList = (asset: AssetEntity) => + !shouldBeInRemovedList(asset) && + (!isIncremental || + ((isNew(asset) || wasSavedWithZeroAmount(asset)) && + hasNonZeroAmount(asset))); + + const assetListUpdatedPayload = assets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [ + ...(acc[asset.keyringAccountId]?.added ?? []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), + ], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), + ], + }, + }), + {}, + ); + + // If no assets were added or removed, don't emit the event. + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + // Notify the extension about the changed balances in a single event + + const hasChanged = (asset: AssetEntity) => + AssetsService.hasChanged(asset, savedAssets); + + /** + * Build the event payload for snap keyring event `AccountBalancesUpdated`. + * + * @example + * { + * "balances": { + * "keyringAccountId0": { + * "assetType00": { + * "unit": "XYZ", + * "amount": "1234" + * }, + * "assetType01": { + * "unit": "ABC", + * "amount": "5678" + * } + * }, + * "keyringAccountId1": { + * "assetType10": { + * "unit": "XYZ", + * "amount": "42" + * } + * } + * } + * } + */ + const balancesUpdatedPayload = assets + .filter(isIncremental ? hasChanged : () => true) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + /** + * Checks if the asset has changed compared to passed assets lookup. + * + * @param asset - The asset to check. + * @param assetsLookup - The lookup table to check against. + * @returns True if the asset has changed, false otherwise. + */ + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + const savedAsset = assetsLookup.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + if (!savedAsset) { + return true; + } + + const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; + const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; + + return rawAmountChanged || uiAmountChanged; + } + + async getAll(): Promise { + return this.#assetsRepository.getAll(); + } + + async findByAccount(account: SolanaKeyringAccount): Promise { + const { id: keyringAccountId, address } = account; + + const savedAssets = + await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); + + // Every account must have at least the native assets. Ensure that they are always present, even if not yet fetched/saved. + const nativeAssetTypes = await this.getNativeAssetTypes(); + const missingNativeAssets: NativeAsset[] = []; + + for (const nativeAssetType of nativeAssetTypes) { + const hasNativeAsset = savedAssets.some( + (asset) => asset.assetType === nativeAssetType, + ); + + if (!hasNativeAsset) { + // Create a placeholder native asset with zero balance + // This will be updated when assets are actually fetched + const network = getNetworkFromToken(nativeAssetType); + + missingNativeAssets.push({ + assetType: nativeAssetType, + keyringAccountId: account.id, + network, + address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }); + } + } + + return [...savedAssets, ...missingNativeAssets]; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.test.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.test.ts new file mode 100644 index 00000000..3fc5ba30 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.test.ts @@ -0,0 +1,171 @@ +import type { Mint } from '@solana-program/token-2022'; +import type { Account, Address } from '@solana/kit'; +import { lamports } from '@solana/kit'; +import { cloneDeep } from 'lodash'; + +import { Network } from '../../constants/solana'; +import type { SolanaConnection } from '../connection/SolanaConnection'; +import { createMockConnection } from '../mocks/mockConnection'; +import { MOCK_MINT_ACCOUNT } from '../mocks/mockSolanaRpcResponses'; +import { TokenHelper } from './TokenHelper'; + +describe('TokenHelper', () => { + let tokenHelper: TokenHelper; + let mockConnection: SolanaConnection; + + const createMockMintAccount = (): Account => + cloneDeep(MOCK_MINT_ACCOUNT); + + const mockMint = MOCK_MINT_ACCOUNT.address; + + beforeEach(() => { + mockConnection = createMockConnection(); + tokenHelper = new TokenHelper(mockConnection); + }); + + describe('when the mint has no multiplier', () => { + const mockMintAccountNoMultiplier = createMockMintAccount(); + + beforeEach(() => { + jest + .spyOn(mockConnection, 'fetchMint') + .mockResolvedValue(mockMintAccountNoMultiplier); + }); + + it('returns the uiAmount in lamports', async () => { + const result = await tokenHelper.uiAmountToAmountForMint( + mockMint, + Network.Mainnet, + '1000', + ); + expect(result).toBe(lamports(1000n * 10n ** 6n)); + }); + + it('handles very large amounts without BigInt parsing errors', async () => { + // This is the "unlimited" sentinel value used by the Card team + const unlimitedAmount = '2199023255551'; + const result = await tokenHelper.uiAmountToAmountForMint( + mockMint, + Network.Mainnet, + unlimitedAmount, + ); + // 2199023255551 * 10^6 = 2199023255551000000n + expect(result).toBe(lamports(2199023255551000000n)); + }); + }); + + describe('when the mint has a multiplier', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2022-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when the extension is ScaledUiAmountConfig', () => { + describe('when the new multiplier is not yet effective', () => { + const nowInSeconds = Math.floor(Date.now() / 1000); + const extension = { + __kind: 'ScaledUiAmountConfig' as const, + multiplier: 1.5, + newMultiplier: 2, + newMultiplierEffectiveTimestamp: BigInt(nowInSeconds + 1000), // The new multiplier is not yet effective + authority: mockMint, + }; + + it('converts the uiAmount to the raw amount correctly', async () => { + const mockMintAccount = createMockMintAccount(); + mockMintAccount.data.extensions = { + __option: 'Some', + value: [extension], + }; + + jest + .spyOn(mockConnection, 'fetchMint') + .mockResolvedValue(mockMintAccount); + + const amount = await tokenHelper.uiAmountToAmountForMint( + mockMint, + Network.Mainnet, + '1000', + ); + + expect(amount).toBe(666666666n); + }); + }); + + describe('when the new multiplier is already effective', () => { + const extension = { + __kind: 'ScaledUiAmountConfig' as const, + multiplier: 1.5, + newMultiplier: 2, + newMultiplierEffectiveTimestamp: 0n, + authority: mockMint, + }; + + it('converts the uiAmount to the amount in lamports correctly', async () => { + const mockMintAccount = createMockMintAccount(); + mockMintAccount.data.extensions = { + __option: 'Some', + value: [extension], + }; + + jest + .spyOn(mockConnection, 'fetchMint') + .mockResolvedValue(mockMintAccount); + + const amount = await tokenHelper.uiAmountToAmountForMint( + mockMint, + Network.Mainnet, + '1000', + ); + + expect(amount).toBe(500000000n); + }); + }); + }); + + describe('when the extension is InterestBearingConfig', () => { + let extension: any; + + beforeEach(() => { + const nowInSeconds = Math.floor(Date.now() / 1000); + + extension = { + __kind: 'InterestBearingConfig' as const, + // Last update was 1 day ago (in seconds) + lastUpdateTimestamp: BigInt(nowInSeconds - 86400), + // The interest bearing extension was initialized 30 days ago (in seconds) + initializationTimestamp: BigInt(nowInSeconds - 86400 * 30), + // Interest rate in basis points (1 basis point = 0.01%) before last update + preUpdateAverageRate: 500, // 5% + // Current interest rate in basis points + currentRate: 700, // 7% + rateAuthority: mockMint, + }; + }); + + it('converts the uiAmount to the raw amount correctly', async () => { + const mockMintAccount = createMockMintAccount(); + mockMintAccount.data.extensions = { + __option: 'Some', + value: [extension], + }; + + jest + .spyOn(mockConnection, 'fetchMint') + .mockResolvedValue(mockMintAccount); + + const amount = await tokenHelper.uiAmountToAmountForMint( + mockMint, + Network.Mainnet, + '1000', + ); + + expect(amount).toBe(995847000n); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.ts new file mode 100644 index 00000000..32e349cf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/TokenHelper.ts @@ -0,0 +1,179 @@ +import type { Mint } from '@solana-program/token-2022'; +import { + uiAmountToAmountForInterestBearingMintWithoutSimulation, + uiAmountToAmountForScaledUiAmountMintWithoutSimulation, +} from '@solana-program/token-2022'; +import type { Account, Address, Lamports } from '@solana/kit'; +import { lamports, unwrapOption } from '@solana/kit'; +import BigNumber from 'bignumber.js'; + +import type { Network } from '../../constants/solana'; +import type { SolanaConnection } from '../connection'; + +export class TokenHelper { + readonly #connection: SolanaConnection; + + constructor(connection: SolanaConnection) { + this.#connection = connection; + } + + /** + * Converts a UI amount to the raw amount in lamports for a given mint, accounting for + * any special mint extensions such as interest bearing or scaled UI amount mints. + * + * This is used for accurate cosmetic balance calculations (e.g., yield, dividends, splits) + * and does not affect the token program's internal transfer logic. + * + * If no extension is present, the conversion is a simple decimal shift. + * + * Based on the implementation from @solana-labs token-2022. + * + * @see https://github.com/solana-program/token-2022/blob/rust-legacy%40v0.17.0/clients/js/src/amountToUiAmount.ts#L329 + * @param mintAddress - The mint address. + * @param network - The network to fetch the mint account from. + * @param uiAmount - The UI amount to convert. + * @returns The amount in lamports as a Lamports type. + */ + async uiAmountToAmountForMint( + mintAddress: string, + network: Network, + uiAmount: string, + ): Promise { + const mintAccount = await this.#connection.fetchMint(mintAddress, network); + return this.#uiAmountToAmountForMintAccount(mintAccount, uiAmount); + } + + /** + * Reverse operation of {@link uiAmountToAmountForMint}. + * Instead of re-implementing the logic, we use a mathematical trick to find the multiplier. + * + * @param mintAddress - The mint address. + * @param network - The network to fetch the mint account from. + * @param amount - The amount in lamports to convert. + * @returns The UI amount as a string. + */ + async amountToUiAmountForMint( + mintAddress: string, + network: Network, + amount: Lamports, + ): Promise { + const mintAccount = await this.#connection.fetchMint(mintAddress, network); + return this.#amountToUiAmountForMintAccount(mintAccount, amount); + } + + /** + * Internal method that works with a mint account directly. + * Converts a UI amount to the raw amount in lamports for a given mint account. + * + * @param mintAccount - The mint account. + * @param uiAmount - The UI amount. + * @returns The amount in lamports as a Lamports type. + */ + #uiAmountToAmountForMintAccount( + mintAccount: Account, + uiAmount: string, + ): Lamports { + try { + const extensions = unwrapOption(mintAccount.data?.extensions); + const { decimals } = mintAccount.data ?? {}; + + // Check for interest bearing mint extension + const interestBearingMintConfigState: any = extensions?.find( + (item: any) => item.__kind === 'InterestBearingConfig', + ); + + // Check for scaled UI amount extension + const scaledUiAmountConfig: any = extensions?.find( + (item: any) => item.__kind === 'ScaledUiAmountConfig', + ); + + // If no special extension, the amount in lamports is simply the uiAmount converted to lamports + if (!interestBearingMintConfigState && !scaledUiAmountConfig) { + return lamports( + BigInt( + BigNumber(uiAmount) + .multipliedBy(10 ** decimals) + .toFixed(0), + ), + ); + } + + // Get timestamp if needed for special mint types + const timestamp = Date.now() / 1000; + + // Handle interest bearing mint + if (interestBearingMintConfigState) { + const rawAmountLamports = + uiAmountToAmountForInterestBearingMintWithoutSimulation( + uiAmount, + decimals, + Number(timestamp), + Number(interestBearingMintConfigState.lastUpdateTimestamp), + Number(interestBearingMintConfigState.initializationTimestamp), + interestBearingMintConfigState.preUpdateAverageRate, + interestBearingMintConfigState.currentRate, + ); + return lamports(rawAmountLamports); + } + + // At this point, we know it must be a scaled UI amount mint + if (scaledUiAmountConfig) { + let { multiplier } = scaledUiAmountConfig; + // Use new multiplier if it's effective + if (timestamp >= scaledUiAmountConfig.newMultiplierEffectiveTimestamp) { + multiplier = scaledUiAmountConfig.newMultiplier; + } + const rawAmountLamports = + uiAmountToAmountForScaledUiAmountMintWithoutSimulation( + uiAmount, + decimals, + multiplier, + ); + return lamports(rawAmountLamports); + } + + // This should never happen due to the conditions above + throw new Error('Unknown mint extension type'); + } catch (error: any) { + const decimals = mintAccount?.data?.decimals ?? 9; + return lamports( + BigInt( + BigNumber(uiAmount) + .multipliedBy(10 ** decimals) + .toFixed(0), + ), + ); + } + } + + /** + * Internal method that works with a mint account directly. + * Converts an amount in lamports to a UI amount for a given mint account. + * + * @param mintAccount - The mint account. + * @param amount - The amount in lamports. + * @returns The UI amount as a string. + */ + #amountToUiAmountForMintAccount( + mintAccount: Account, + amount: Lamports, + ): string { + try { + const fakeUiAmount = 1000000000n; + + const multiplier = BigNumber( + this.#uiAmountToAmountForMintAccount( + mintAccount, + fakeUiAmount.toString(), + ).toString(), + ).dividedBy(fakeUiAmount.toString()); + + return BigNumber(amount.toString()).dividedBy(multiplier).toString(); + } catch (error: any) { + const decimals = mintAccount.data?.decimals ?? 9; + return BigNumber(amount.toString()) + .dividedBy(10 ** decimals) + .toString(); + } + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/index.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/index.ts new file mode 100644 index 00000000..cfec7e81 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/index.ts @@ -0,0 +1,3 @@ +export * from './AssetsRepository'; +export * from './AssetsService'; +export * from './TokenHelper'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/assets/types.ts b/merged-packages/solana-wallet-snap/src/core/services/assets/types.ts new file mode 100644 index 00000000..c9673a32 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/assets/types.ts @@ -0,0 +1,62 @@ +import type { CaipAssetType, FungibleAssetMetadata } from '@metamask/snaps-sdk'; + +import type { Balance } from '../../clients/nft-api/types'; +import type { Caip10Address } from '../../constants/solana'; + +export type NonFungibleAssetCollection = { + // Human-friendly name of the asset collection. + name: string; + + // The collection address. + address: Caip10Address; + + // Ticker symbol of the asset collection. + symbol: string; + + // The number of tokens in the collection. + tokenCount?: number; + + // The creator address of the asset. + creator?: Caip10Address; + + // Base64 data URI or URL representation of the asset icon. + imageUrl?: string; +}; + +export type NonFungibleAssetMetadata = { + // Human-friendly name of the asset. + name?: string; + + // Ticker symbol of the asset. + symbol?: string; + + // Base64 data URI or URL representation of the asset image. + imageUrl?: string; + + // The description of the asset. + description?: string; + + // Represents a non-fungible asset + fungible: false; + + // The time at which the asset was acquired. + // The time is represented as a UNIX timestamp. + acquiredAt?: number; + + // Indicates whether the asset is possibly a spam asset. + isPossibleSpam?: boolean; + + // Attributes of the non-fungible asset. + attributes?: Record; + + // The collection of the asset. + collection?: NonFungibleAssetCollection; +}; + +export type AssetMetadata = FungibleAssetMetadata | NonFungibleAssetMetadata; + +export type SaveAssetParams = { + accountId: string; + assetType: CaipAssetType; + balance: Balance; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/config/ConfigProvider.ts b/merged-packages/solana-wallet-snap/src/core/services/config/ConfigProvider.ts new file mode 100644 index 00000000..465100f1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/config/ConfigProvider.ts @@ -0,0 +1,267 @@ +/* eslint-disable no-restricted-globals */ +import type { Infer } from '@metamask/superstruct'; +import { + array, + coerce, + create, + enums, + object, + string, +} from '@metamask/superstruct'; +import { Duration } from '@metamask/utils'; +import { uniq } from 'lodash'; + +import { Network, Networks } from '../../constants/solana'; +import { getClientStatus } from '../../utils/interface'; +import { UrlStruct } from '../../validation/structs'; + +export const SUPPORTED_NETWORKS = [Network.Mainnet, Network.Devnet]; + +const ENVIRONMENT_TO_ACTIVE_NETWORKS: Record = { + production: [Network.Mainnet], + local: [Network.Mainnet], + test: [Network.Localnet], +}; + +const CommaSeparatedListOfUrlsStruct = coerce( + array(UrlStruct), + string(), + (value: string) => value.split(','), +); + +const CommaSeparatedListOfStringsStruct = coerce( + array(string()), + string(), + (value: string) => value.split(','), +); + +const EnvStruct = object({ + ENVIRONMENT: enums(['local', 'test', 'production']), + RPC_URL_MAINNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_DEVNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_TESTNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_LOCALNET_LIST: CommaSeparatedListOfStringsStruct, + RPC_WEB_SOCKET_URL_MAINNET: UrlStruct, + RPC_WEB_SOCKET_URL_DEVNET: UrlStruct, + RPC_WEB_SOCKET_URL_TESTNET: UrlStruct, + RPC_WEB_SOCKET_URL_LOCALNET: UrlStruct, + EXPLORER_BASE_URL: UrlStruct, + PRICE_API_BASE_URL: UrlStruct, + TOKEN_API_BASE_URL: UrlStruct, + STATIC_API_BASE_URL: UrlStruct, + SECURITY_ALERTS_API_BASE_URL: UrlStruct, + NFT_API_BASE_URL: UrlStruct, + LOCAL_API_BASE_URL: string(), +}); + +export type Env = Infer; + +export type NetworkConfig = (typeof Networks)[Network] & { + rpcUrls: string[]; + webSocketUrl: string; +}; + +export type Config = { + environment: string; + networks: NetworkConfig[]; + explorerBaseUrl: string; + priceApi: { + baseUrl: string; + chunkSize: number; + cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + }; + tokenApi: { + baseUrl: string; + chunkSize: number; + }; + staticApi: { + baseUrl: string; + }; + transactions: { + storageLimit: number; + }; + securityAlertsApi: { + baseUrl: string; + }; + nftApi: { + baseUrl: string; + cacheTtlsMilliseconds: { + listAddressSolanaNfts: number; + getNftMetadata: number; + }; + }; + subscriptions: { + maxReconnectAttempts: number; + reconnectDelayMilliseconds: number; + closeConnectionsGracePeriodMilliseconds: number; + }; +}; + +/** + * A utility class that provides the configuration of the snap. + * + * @example + * const configProvider = new ConfigProvider(); + * const { networks } = configProvider.get(); + * @example + * // You can use utility methods for more advanced manipulations. + * const network = configProvider.getNetworkBy('caip2Id', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + */ +export class ConfigProvider { + readonly #config: Config; + + #activeNetworks: Network[]; + + constructor() { + const environment = this.#parseEnvironment(); + this.#config = this.#buildConfig(environment); + this.#activeNetworks = []; + } + + #parseEnvironment() { + const rawEnvironment = { + ENVIRONMENT: process.env.ENVIRONMENT, + RPC_URL_MAINNET_LIST: process.env.RPC_URL_MAINNET_LIST, + RPC_URL_DEVNET_LIST: process.env.RPC_URL_DEVNET_LIST, + RPC_URL_TESTNET_LIST: process.env.RPC_URL_TESTNET_LIST, + RPC_URL_LOCALNET_LIST: process.env.RPC_URL_LOCALNET_LIST, + RPC_WEB_SOCKET_URL_MAINNET: process.env.RPC_WEB_SOCKET_URL_MAINNET, + RPC_WEB_SOCKET_URL_DEVNET: process.env.RPC_WEB_SOCKET_URL_DEVNET, + RPC_WEB_SOCKET_URL_TESTNET: process.env.RPC_WEB_SOCKET_URL_TESTNET, + RPC_WEB_SOCKET_URL_LOCALNET: process.env.RPC_WEB_SOCKET_URL_LOCALNET, + EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL, + PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL, + TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL, + STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL, + SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, // Blockaid + LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, + // NFT API + NFT_API_BASE_URL: process.env.NFT_API_BASE_URL, + }; + + // Validate and parse them before returning + return create(rawEnvironment, EnvStruct); + } + + #buildConfig(environment: Env): Config { + return { + environment: environment.ENVIRONMENT, + networks: [ + { + ...Networks[Network.Mainnet], + rpcUrls: environment.RPC_URL_MAINNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_MAINNET, + }, + { + ...Networks[Network.Devnet], + rpcUrls: environment.RPC_URL_DEVNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_DEVNET, + }, + { + ...Networks[Network.Testnet], + rpcUrls: environment.RPC_URL_TESTNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_TESTNET, + }, + { + ...Networks[Network.Localnet], + rpcUrls: environment.RPC_URL_LOCALNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_LOCALNET, + }, + ], + explorerBaseUrl: environment.EXPLORER_BASE_URL, + priceApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.PRICE_API_BASE_URL, + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: Duration.Minute, + spotPrices: Duration.Minute, + historicalPrices: Duration.Minute, + }, + }, + tokenApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.TOKEN_API_BASE_URL, + chunkSize: 50, + }, + staticApi: { + baseUrl: environment.STATIC_API_BASE_URL, + }, + transactions: { + storageLimit: 10, + }, + securityAlertsApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.SECURITY_ALERTS_API_BASE_URL, + }, + nftApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.NFT_API_BASE_URL, + cacheTtlsMilliseconds: { + listAddressSolanaNfts: Duration.Minute, + getNftMetadata: Duration.Minute, + }, + }, + subscriptions: { + maxReconnectAttempts: 5, + reconnectDelayMilliseconds: Duration.Second, + /** + * The time we wait before closing the connections when the extension becomes inactive. + * This is to avoid closing and opening the connections too much when the user switches back and forth between the client and a dapp for instance. + */ + closeConnectionsGracePeriodMilliseconds: Duration.Minute * 5, + }, + }; + } + + public get(): Config { + return this.#config; + } + + public getNetworkBy(key: keyof NetworkConfig, value: string): NetworkConfig { + const network = this.get().networks.find((item) => item[key] === value); + if (!network) { + throw new Error(`Network ${key} not found`); + } + return network; + } + + async getActiveNetworks(): Promise { + // If the active networks are already set, return them + if (this.#activeNetworks.length > 0) { + return this.#activeNetworks; + } + + const baseNetworks = uniq([ + Network.Mainnet, + ...(ENVIRONMENT_TO_ACTIVE_NETWORKS[this.#config.environment] ?? []), + ]); + + try { + // Otherwise, fetch them from the client + const { clientVersion } = await getClientStatus(); + const isFlask = clientVersion.includes('flask'); + const flaskNetworks = isFlask ? [Network.Devnet] : []; + + const activeNetworks = uniq([...baseNetworks, ...flaskNetworks]); + + // Set the active networks + this.#activeNetworks = activeNetworks; + return this.#activeNetworks; + } catch (error) { + return baseNetworks; + } + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/config/index.ts b/merged-packages/solana-wallet-snap/src/core/services/config/index.ts new file mode 100644 index 00000000..6564831c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/config/index.ts @@ -0,0 +1 @@ +export { ConfigProvider } from './ConfigProvider'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts b/merged-packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts new file mode 100644 index 00000000..dcc1842e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/confirmation/ConfirmationHandler.ts @@ -0,0 +1,184 @@ +import { SolMethod } from '@metamask/keyring-api'; +import { assert, union } from '@metamask/superstruct'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import { render as renderConfirmSignIn } from '../../../features/confirmation/views/ConfirmSignIn/render'; +import { render as renderConfirmSignMessage } from '../../../features/confirmation/views/ConfirmSignMessage/render'; +import { + DEFAULT_CONFIRMATION_CONTEXT, + render as renderConfirmTransactionRequest, +} from '../../../features/confirmation/views/ConfirmTransactionRequest/render'; +import { ScheduleBackgroundEventMethod } from '../../handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; +import type { SolanaKeyringRequest } from '../../handlers/onKeyringRequest/structs'; +import { + SolanaSignAndSendTransactionRequestStruct, + SolanaSignTransactionRequestStruct, +} from '../wallet/structs'; + +/** + * Wraps the confirmation logic for the keyring API. + * + * It is responsible for rendering the confirmation UI and handling the side effects that need to happen when the transaction is shown in confirmation UI. + */ +export class ConfirmationHandler { + /** + * Handles the confirmation of a request, based on the method of the request. + * Renders the appropriate confirmation UI for the request, and returns whether the request was confirmed. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns Whether the request was confirmed. + */ + async handleKeyringRequest( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, + ): Promise { + const { + request: { method }, + } = request; + + switch (method) { + case SolMethod.SignAndSendTransaction: + return this.#handleConfirmTransactionRequest(request, account); + case SolMethod.SignTransaction: + return this.#handleConfirmTransactionRequest(request, account); + case SolMethod.SignMessage: + return this.#handleConfirmSignMessage(request, account); + case SolMethod.SignIn: + return this.#handleConfirmSignIn(request, account); + default: + throw new Error(`Unsupported method: ${method}`); + } + } + + /** + * Handles the confirmation whenever a transaction needs to be signed: + * - Sign and send transaction request. + * - Sign transaction request. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns Whether the request was confirmed. + */ + async #handleConfirmTransactionRequest( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, + ): Promise { + assert( + request.request, + union([ + SolanaSignAndSendTransactionRequestStruct, + SolanaSignTransactionRequestStruct, + ]), + ); + + const { + request: { + method, + params: { transaction: base64EncodedTransaction }, + }, + scope, + account: accountId, + origin, + } = request; + + // Trigger the side effects that need to happen when the transaction is shown in confirmation UI + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionAdded, + params: { + accountId, + metadata: { + scope, + origin, + }, + }, + }, + }, + }); + + const isConfirmed = await renderConfirmTransactionRequest({ + ...DEFAULT_CONFIRMATION_CONTEXT, + scope, + method, + origin: request.origin, + transaction: base64EncodedTransaction, + account, + }); + + if (isConfirmed) { + // Trigger the side effects that need to happen when the transaction is approved + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionApproved, + params: { + accountId, + metadata: { + scope, + origin, + }, + }, + }, + }, + }); + + return true; + } + + // Trigger the side effects that need to happen when the transaction is rejected + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionRejected, + params: { + accountId, + metadata: { + scope, + origin, + }, + }, + }, + }, + }); + + return false; + } + + /** + * Handles the confirmation of a sign message request. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns Whether the request was confirmed. + */ + async #handleConfirmSignMessage( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, + ): Promise { + const isConfirmed = await renderConfirmSignMessage(request, account); + return Boolean(isConfirmed); + } + + /** + * Handles the confirmation of a sign in request. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns Whether the request was confirmed. + */ + async #handleConfirmSignIn( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, + ): Promise { + const isConfirmed = await renderConfirmSignIn(request, account); + return Boolean(isConfirmed); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts new file mode 100644 index 00000000..d6e01113 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable no-restricted-globals */ + +import { fetchJsonParsedAccount } from '@solana/kit'; + +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { KnownCaip19Id, Network } from '../../constants/solana'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../config'; +import { mockLogger } from '../mocks/logger'; +import { + MOCK_JSON_PARSED_ACCOUNT, + MOCK_MINT_ACCOUNT, +} from '../mocks/mockSolanaRpcResponses'; +import { SolanaConnection } from './SolanaConnection'; + +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + createSolanaRpcFromTransport: jest.fn().mockImplementation((transport) => ({ + urls: transport.urls, + })), + address: jest.fn().mockImplementation((address) => address), + fetchJsonParsedAccount: jest.fn(), +})); + +jest.mock('@solana-program/token-2022', () => ({ + fetchMint: jest.fn().mockResolvedValue(MOCK_MINT_ACCOUNT), +})); + +jest.mock('./transport', () => ({ + createMainTransport: jest.fn().mockImplementation((urls) => ({ + urls, + })), +})); + +const MOCK_NETWORKS = [ + { + caip2Id: Network.Mainnet, + rpcUrls: ['https://mainnet.com'], + }, + { + caip2Id: Network.Devnet, + rpcUrls: ['https://devnet.com'], + }, +]; + +describe('SolanaConnection', () => { + let connection: SolanaConnection; + let mockConfigProvider: ConfigProvider; + let mockCache: ICache; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + networks: MOCK_NETWORKS, + }), + getNetworkBy: jest.fn().mockImplementation((key, value) => { + switch (key) { + case 'caip2Id': + return MOCK_NETWORKS.find((network) => network.caip2Id === value); + default: + throw new Error('Implement the case.'); + } + }), + } as unknown as ConfigProvider; + + mockCache = new InMemoryCache(mockLogger); + + connection = new SolanaConnection(mockConfigProvider, mockCache); + }); + + describe('getRpc', () => { + it('returns the correct RPC client for a valid network', () => { + const rpc = connection.getRpc(Network.Mainnet); + expect(rpc).toBeDefined(); + expect(rpc).toStrictEqual({ + urls: ['https://mainnet.com'], + }); + + const rpcDevnet = connection.getRpc(Network.Devnet); + expect(rpcDevnet).toBeDefined(); + expect(rpcDevnet).toStrictEqual({ + urls: ['https://devnet.com'], + }); + }); + + it('returns the same RPC client for the same network', () => { + const rpc1 = connection.getRpc(Network.Mainnet); + const rpc2 = connection.getRpc(Network.Mainnet); + expect(rpc1).toBe(rpc2); + }); + + it('returns different RPC clients for different networks', () => { + const rpc1 = connection.getRpc(Network.Mainnet); + const rpc2 = connection.getRpc(Network.Devnet); + expect(rpc1).not.toBe(rpc2); + }); + + it('throws an error for an invalid network', () => { + expect(() => { + connection.getRpc('invalid-network' as Network); + }).toThrow(/Expected one of/u); + }); + }); + + describe('fetchJsonParsedAccount', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (fetchJsonParsedAccount as jest.Mock).mockResolvedValue( + MOCK_JSON_PARSED_ACCOUNT, + ); + }); + + it('returns the JSON-parsed account', async () => { + const jsonParsedAccount = await connection.fetchJsonParsedAccount( + KnownCaip19Id.UsdcMainnet, + Network.Mainnet, + ); + + expect(jsonParsedAccount).toStrictEqual(MOCK_JSON_PARSED_ACCOUNT); + }); + + it('caches the JSON-parsed account', async () => { + const spy = jest.spyOn(require('@solana/kit'), 'fetchJsonParsedAccount'); + + const call = async () => + connection.fetchJsonParsedAccount( + KnownCaip19Id.UsdcMainnet, + Network.Mainnet, + ); + + // Do the call twice in a row to test the cache + await call(); + await call(); + + // It should effectively call the RPC only once + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('bypasses the cache when skipCache is true', async () => { + const spy = jest.spyOn(require('@solana/kit'), 'fetchJsonParsedAccount'); + + const call = async () => + connection.fetchJsonParsedAccount( + KnownCaip19Id.UsdcMainnet, + Network.Mainnet, + undefined, + { skipCache: true }, + ); + + await call(); + await call(); + + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + describe('fetchMint', () => { + it('returns the mint account', async () => { + const mint = await connection.fetchMint( + KnownCaip19Id.UsdcMainnet, + Network.Mainnet, + ); + + expect(mint).toStrictEqual(MOCK_MINT_ACCOUNT); + }); + + it('caches the mint account', async () => { + const spy = jest.spyOn( + require('@solana-program/token-2022'), + 'fetchMint', + ); + + const call = async () => + connection.fetchMint(KnownCaip19Id.UsdcMainnet, Network.Mainnet); + + await call(); + await call(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts new file mode 100644 index 00000000..333f0a4a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts @@ -0,0 +1,187 @@ +import { assert } from '@metamask/superstruct'; +import { Duration } from '@metamask/utils'; +import { fetchMint } from '@solana-program/token-2022'; +import type { Mint } from '@solana-program/token-2022'; +import { + address as asAddress, + createSolanaRpcFromTransport, + fetchJsonParsedAccount, +} from '@solana/kit'; +import type { + Account, + Address, + Blockhash, + FetchAccountConfig, + MaybeAccount, + MaybeEncodedAccount, +} from '@solana/kit'; +import type { Rpc, SolanaRpcApi } from '@solana/kit'; + +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { Network } from '../../constants/solana'; +import type { Serializable } from '../../serialization/types'; +import { NetworkStruct } from '../../validation/structs'; +import type { ConfigProvider } from '../config/ConfigProvider'; +import { createMainTransport } from './transport'; + +/** + * The SolanaConnection class is responsible for managing the connections to the Solana networks. + */ +export class SolanaConnection { + readonly #configProvider: ConfigProvider; + + readonly #cache: ICache; + + readonly #cacheTtlsMilliseconds = { + fetchMint: Duration.Minute, + fetchJsonParsedAccount: Duration.Minute, + }; + + /** + * A mapping of Solana network CAIP-2 IDs to their respective RPC clients. + * + * Each network has its own RPC connection for making JSON-RPC requests + * to the Solana blockchain. + */ + readonly #networkCaip2IdToRpc: Map> = new Map(); + + constructor(configProvider: ConfigProvider, cache: ICache) { + this.#configProvider = configProvider; + this.#cache = cache; + } + + #createRpc(caip2Id: Network): Rpc { + const network = this.#configProvider.getNetworkBy('caip2Id', caip2Id); + const transport = createMainTransport(network.rpcUrls); + const rpc = createSolanaRpcFromTransport(transport); + this.#networkCaip2IdToRpc.set(caip2Id, rpc); + return rpc; + } + + /** + * Returns the RPC client for the given network CAIP-2 ID. + * If the RPC client does not exist, it creates a new one + * and stores it in the map. + * + * @param caip2Id - The CAIP-2 ID of the network. + * @returns The RPC client for the given network CAIP-2 ID. + */ + public getRpc(caip2Id: Network): Rpc { + assert(caip2Id, NetworkStruct); + return this.#networkCaip2IdToRpc.get(caip2Id) ?? this.#createRpc(caip2Id); + } + + /** + * Fetches the JSON-parsed account for the given address and network. + * It's a wrapper around the fetchJsonParsedAccount function that caches the result for 1 minute. + * + * @param address - The address of the account. + * @param caip2Id - The CAIP-2 ID of the network. + * @param config - The config for the fetchJsonParsedAccount function. + * @param options - Additional options. + * @param options.skipCache - When true, bypasses the cache and always fetches + * from the RPC. Use this when the result gates a non-idempotent operation + * (e.g. deciding whether to include a Create ATA instruction). + * @returns The JSON-parsed account. + */ + public async fetchJsonParsedAccount( + address: string, + caip2Id: Network, + config?: FetchAccountConfig, + options?: { skipCache?: boolean }, + ): Promise | MaybeEncodedAccount
> { + /** + * Defines the base uncached function for fetching a JSON-parsed account. + * + * This wrapper is used instead of directly caching the SDK's fetchJsonParsedAccount function + * to ensure that only simple arguments are used, as these arguments form the cache key. + */ + + const internal = async ( + _address: string, + _caip2Id: Network, + _config?: FetchAccountConfig, + ) => { + const rpc = this.getRpc(_caip2Id); + return fetchJsonParsedAccount(rpc, asAddress(_address), _config); + }; + + if (options?.skipCache) { + return internal(address, caip2Id, config) as Promise< + MaybeAccount | MaybeEncodedAccount
+ >; + } + + // Create a cached version of the function + const cached = useCache< + [string, Network, FetchAccountConfig | undefined], + | (MaybeAccount & Serializable) + | (MaybeEncodedAccount
& Serializable) + >(internal as any, this.#cache, { + ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchJsonParsedAccount, + functionName: 'SolanaConnection::fetchJsonParsedAccount', + }); + + return cached(address, caip2Id, config); + } + + /** + * Returns the mint account for the given address and network. + * + * It's a wrapper around the fetchMint function that caches the result for 1 minute. + * + * @param address - The address of the mint. + * @param caip2Id - The CAIP-2 ID of the network. + * @param config - The config for the fetchMint function. + * @returns The mint. + */ + public async fetchMint( + address: string, + caip2Id: Network, + config?: FetchAccountConfig, + ): Promise> { + /** + * Defines the base uncached function for fetching a mint account. + * + * This wrapper is used instead of directly caching the SDK's fetchMint function + * to ensure that only simple arguments are used, as these arguments form the cache key. + */ + + const fetchMintInternal = async ( + _address: Address, + _caip2Id: Network, + _config?: FetchAccountConfig, + ) => { + const rpc = this.getRpc(caip2Id); + return fetchMint(rpc, asAddress(address), config); + }; + + // Create a cached version of the function + const fetchMintCached = useCache< + [Address, Network, FetchAccountConfig | undefined], + Account & Serializable + >(fetchMintInternal as any, this.#cache, { + ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchMint, + functionName: 'SolanaConnection::fetchMint', + }); + + // Use the cached version of the function + return fetchMintCached(asAddress(address), caip2Id, config); + } + + /** + * Returns the latest blockhash for the given network. + * + * @param caip2Id - The CAIP-2 ID of the network. + * @returns The latest blockhash. + */ + public async getLatestBlockhash(caip2Id: Network): Promise< + Readonly<{ + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }> + > { + return (await this.getRpc(caip2Id).getLatestBlockhash().send()).value; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/index.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/index.ts new file mode 100644 index 00000000..19647b55 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/index.ts @@ -0,0 +1 @@ +export { SolanaConnection } from './SolanaConnection'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/structs.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/structs.ts new file mode 100644 index 00000000..70ac78a9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/structs.ts @@ -0,0 +1,46 @@ +import { + array, + bigint, + boolean, + number, + string, + type, +} from '@metamask/superstruct'; + +export const GetBalanceResponseStruct = type({ + context: type({ + slot: bigint(), + }), + value: bigint(), +}); + +const TokenAmountStruct = type({ + amount: string(), + decimals: number(), +}); + +const TokenAccountInfoStruct = type({ + mint: string(), + owner: string(), + isNative: boolean(), + tokenAmount: TokenAmountStruct, +}); + +const TokenAccountDataStruct = type({ + parsed: type({ + info: TokenAccountInfoStruct, + }), +}); + +const TokenAccountStruct = type({ + account: type({ + data: TokenAccountDataStruct, + }), +}); + +export const GetTokenAccountsByOwnerResponseStruct = type({ + context: type({ + slot: bigint(), + }), + value: array(TokenAccountStruct), +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransport.test.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransport.test.ts new file mode 100644 index 00000000..8bc55abb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransport.test.ts @@ -0,0 +1,77 @@ +import { createDefaultRpcTransport } from '@solana/kit'; + +import { createToggleInfuraBigtableLookupsTransport } from './createToggleInfuraBigtableLookupsTransports'; + +// Mock the createDefaultRpcTransport function +jest.mock('@solana/kit', () => ({ + createDefaultRpcTransport: jest.fn(), +})); + +describe('createToggleInfuraBigtableLookupsTransport', () => { + const mockUrl = 'https://test-url.com'; + const mockTransportWithBigtableEnabled = jest.fn(); + const mockTransportWithBigtableDisabled = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createDefaultRpcTransport as jest.Mock).mockImplementation( + ({ headers }) => { + if (headers?.['x-bigtable'] === 'disabled') { + return mockTransportWithBigtableDisabled; + } + return mockTransportWithBigtableEnabled; + }, + ); + }); + + it('creates transports with correct configurations', () => { + createToggleInfuraBigtableLookupsTransport(mockUrl); + + expect(createDefaultRpcTransport).toHaveBeenCalledTimes(2); + expect(createDefaultRpcTransport).toHaveBeenCalledWith({ url: mockUrl }); + expect(createDefaultRpcTransport).toHaveBeenCalledWith({ + url: mockUrl, + headers: { 'x-bigtable': 'disabled' }, + }); + }); + + it('uses default transport for methods that need BigTable lookups', async () => { + const transport = createToggleInfuraBigtableLookupsTransport(mockUrl); + const mockResponse = { result: 'test' }; + mockTransportWithBigtableEnabled.mockResolvedValue(mockResponse); + + const result = await transport({ + payload: { method: 'getTransaction' }, + }); + + expect(mockTransportWithBigtableEnabled).toHaveBeenCalled(); + expect(mockTransportWithBigtableDisabled).not.toHaveBeenCalled(); + expect(result).toBe(mockResponse); + }); + + it('uses transport with disabled BigTable lookups for methods that do not need BigTable lookups', async () => { + const transport = createToggleInfuraBigtableLookupsTransport(mockUrl); + const mockResponse = { result: 'test' }; + mockTransportWithBigtableDisabled.mockResolvedValue(mockResponse); + + const result = await transport({ + payload: { method: 'getBalance' }, + }); + + expect(mockTransportWithBigtableDisabled).toHaveBeenCalled(); + expect(mockTransportWithBigtableEnabled).not.toHaveBeenCalled(); + expect(result).toBe(mockResponse); + }); + + it('handles all methods that need BigTable lookups', async () => { + const transport = createToggleInfuraBigtableLookupsTransport(mockUrl); + const methods = ['getTransaction', 'getSignaturesForAddress']; + + for (const method of methods) { + await transport({ + payload: { method }, + }); + expect(mockTransportWithBigtableEnabled).toHaveBeenCalled(); + } + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransports.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransports.ts new file mode 100644 index 00000000..7f6926d2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransports.ts @@ -0,0 +1,60 @@ +import { createDefaultRpcTransport } from '@solana/kit'; +import type { RpcTransport } from '@solana/kit'; + +import { noOpLogger } from '../../../utils/logger'; + +const logger = noOpLogger; + +export const METHODS_THAT_NEED_BIGTABLE_LOOKUPS = [ + 'getTransaction', + 'getSignaturesForAddress', +]; + +/** + * Creates a transport that controls BigTable lookups on Infura via the `x-bigtable` header. + * + * For most methods: + * - We DISABLE BigTable lookups using "x-bigtable: disabled" header. + * - This improves performance for most RPC requests that don't need historical data. + * + * For some specific methods that need historical data: + * - We ENABLE BigTable lookups using "x-bigtable: enabled" header. + * - Without BigTable access, these methods would return `null`. + * - The {@link METHODS_THAT_NEED_BIGTABLE_LOOKUPS} array above defines which methods need BigTable enabled. + * + * @param url - The URL to use for the transport. + * @returns A transport that automatically manages BigTable access based on the RPC method. + */ +export const createToggleInfuraBigtableLookupsTransport = ( + url: string, +): RpcTransport => { + const transportWithBigtableEnabled = createDefaultRpcTransport({ url }); + const transportWithBigtableDisabled = createDefaultRpcTransport({ + url, + headers: { + 'x-bigtable': 'disabled', + }, + }); + + // Create a dynamic transport that uses the correct transport based on the method + const dynamicTransport = async ( + ...args: Parameters + ): Promise => { + const { payload } = args[0]; + const { method } = payload as any; + const shouldEnableBigtableLookups = + METHODS_THAT_NEED_BIGTABLE_LOOKUPS.includes(method); + + logger.info( + `[🚌 ToggleInfuraBigtableLookupsTransport] RPC method is ${method}. ${ + shouldEnableBigtableLookups ? 'Enabling' : 'Disabling' + } BigTable lookups.`, + ); + + return shouldEnableBigtableLookups + ? transportWithBigtableEnabled(...args) + : transportWithBigtableDisabled(...args); + }; + + return dynamicTransport; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.test.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.test.ts new file mode 100644 index 00000000..d141c71d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.test.ts @@ -0,0 +1,273 @@ +import { createErrorTrackingTransport } from './errorTrackingTransport'; + +const mockSnap = { + request: jest.fn(), +}; + +describe('createErrorTrackingTransport', () => { + beforeEach(() => { + (globalThis as any).snap = mockSnap; + jest.clearAllMocks(); + }); + + describe('HTTP errors (4xx, 5xx)', () => { + it('should track HTTP 500 errors', async () => { + const mockTransport = jest + .fn() + .mockRejectedValue(new Error('HTTP 500 Internal Server Error')); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('HTTP 500 Internal Server Error'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining('HTTP 500 Internal Server Error'), + }), + }, + }); + }); + + it('should track network timeout errors', async () => { + const mockTransport = jest + .fn() + .mockRejectedValue(new Error('Network timeout')); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('Network timeout'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining('Network timeout'), + }), + }, + }); + }); + }); + + describe('JSON-RPC errors in 2xx responses', () => { + it('should track standard JSON-RPC errors but return the response', async () => { + const mockResponse = { + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: 'RPC error: Invalid request', + }, + }; + + const mockTransport = jest.fn().mockResolvedValue(mockResponse); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + const result = await errorTrackingTransport({ + payload: { method: 'getBalance' }, + }); + + // Should return the response instead of throwing + expect(result).toStrictEqual(mockResponse); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining('RPC error in response'), + }), + }, + }); + }); + }); + + describe('Successful responses', () => { + it('should not track successful responses', async () => { + const mockResponse = { + jsonrpc: '2.0', + id: 1, + result: { + value: 1000000, + }, + }; + + const mockTransport = jest.fn().mockResolvedValue(mockResponse); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + const result = await errorTrackingTransport({ + payload: { method: 'getBalance' }, + }); + + expect(result).toStrictEqual(mockResponse); + expect(mockSnap.request).not.toHaveBeenCalled(); + }); + + it('should not track responses with null result but no error', async () => { + const mockResponse = { + jsonrpc: '2.0', + id: 1, + result: null, + }; + + const mockTransport = jest.fn().mockResolvedValue(mockResponse); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + const result = await errorTrackingTransport({ + payload: { method: 'getBalance' }, + }); + + expect(result).toStrictEqual(mockResponse); + expect(mockSnap.request).not.toHaveBeenCalled(); + }); + }); + + describe('Error tracking failures', () => { + it('should handle error tracking failures gracefully', async () => { + const mockTransport = jest + .fn() + .mockRejectedValue(new Error('Network error')); + mockSnap.request.mockRejectedValue(new Error('Tracking failed')); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('Network error'); + + expect(mockSnap.request).toHaveBeenCalled(); + }); + }); + + describe('Error information extraction', () => { + it('should include method and URL in error tracking', async () => { + const mockTransport = jest + .fn() + .mockRejectedValue(new Error('Test error')); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('Test error'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining('"method":"getBalance"'), + }), + }, + }); + }); + + it('should extract currentUrl from error object when available', async () => { + const mockError = new Error('Network error'); + (mockError as any).currentUrl = 'https://api2.example.com'; + + const mockTransport = jest.fn().mockRejectedValue(mockError); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('Network error'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining( + '"url":"https://api2.example.com"', + ), + }), + }, + }); + }); + + it('should extract status codes from errors', async () => { + const mockError = new Error('HTTP 404 Not Found'); + (mockError as any).status = 404; + + const mockTransport = jest.fn().mockRejectedValue(mockError); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('HTTP 404 Not Found'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining('"statusCode":404'), + }), + }, + }); + }); + }); + + describe('Different error formats', () => { + it('should handle string errors', async () => { + const mockTransport = jest.fn().mockRejectedValue('Simple string error'); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('Simple string error'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining( + '"errorMessage":"Simple string error"', + ), + }), + }, + }); + }); + + it('should handle objects with error property', async () => { + const mockError = { error: { code: -32000, message: 'Server error' } }; + const mockTransport = jest.fn().mockRejectedValue(mockError); + + const errorTrackingTransport = + createErrorTrackingTransport(mockTransport); + + await expect( + errorTrackingTransport({ payload: { method: 'getBalance' } }), + ).rejects.toThrow('{"code":-32000,"message":"Server error"}'); + + expect(mockSnap.request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: expect.stringContaining( + '"errorMessage":"{\\"code\\":-32000,\\"message\\":\\"Server error\\"}"', + ), + }), + }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.ts new file mode 100644 index 00000000..33c5ec20 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/errorTrackingTransport.ts @@ -0,0 +1,171 @@ +import { getJsonError } from '@metamask/snaps-sdk'; +import { isJsonRpcError, isJsonRpcFailure } from '@metamask/utils'; +import { isSolanaError } from '@solana/kit'; +import type { RpcTransport } from '@solana/kit'; + +import { noOpLogger } from '../../../utils/logger'; + +const logger = noOpLogger; + +/** + * Error information that will be tracked. + */ +type ErrorTrackingInfo = { + method: string; + url?: string | undefined; + statusCode?: number | undefined; + errorMessage: string; + errorStack?: string | undefined; + responseData?: any | undefined; + requestParams?: any | undefined; +}; + +/** + * Tracks an error using the snap's error tracking mechanism. + * This function safely handles the error tracking without throwing errors. + * + * @param errorInfo - The error information to track. + */ +async function trackError(errorInfo: ErrorTrackingInfo): Promise { + try { + await snap.request({ + method: 'snap_trackError', + params: { + error: getJsonError(new Error(JSON.stringify(errorInfo))), + }, + }); + + logger.info( + `[🚌 ErrorTrackingTransport] Error tracked: ${errorInfo.method} - ${errorInfo.errorMessage}`, + ); + } catch (trackingError) { + logger.warn( + `[🚌 ErrorTrackingTransport] Failed to track error: ${trackingError}`, + ); + } +} + +/** + * Checks if a response is indeed an error, even if it's a 2xx status code. + * Uses metamask/utils to detect JSON-RPC errors. + * + * @param response - The response to check for errors. + * @returns True if the response contains an error, false otherwise. + */ +function isErrorResponse(response: any): boolean { + if (isJsonRpcError(response) || isJsonRpcFailure(response)) { + return true; + } + + // Also check for Solana RPC error + if (isSolanaError(response)) { + return true; + } + + return false; +} + +/** + * Extracts error information from various error response formats. + * + * @param error - The error to extract information from. + * @param method - The RPC method that was called. + * @returns The extracted error information. + */ +function extractErrorInfo(error: any, method: string): ErrorTrackingInfo { + const errorInfo: ErrorTrackingInfo = { + method, + errorMessage: 'Unknown error', + }; + + // Check if the error has a currentUrl property (from failover transport) + if (error?.currentUrl) { + errorInfo.url = error.currentUrl; + } + + // Handle different error formats + if (error instanceof Error) { + errorInfo.errorMessage = error.message; + errorInfo.errorStack = error.stack; + } else if (typeof error === 'string') { + errorInfo.errorMessage = error; + } else if (error?.message) { + errorInfo.errorMessage = error.message; + } else if (error?.error) { + errorInfo.errorMessage = + typeof error.error === 'string' + ? error.error + : JSON.stringify(error.error); + } + + // Get status code if available + if (error?.status) { + errorInfo.statusCode = error.status; + } else if (error?.statusCode) { + errorInfo.statusCode = error.statusCode; + } + + // Get response data if available + if (error?.response) { + errorInfo.responseData = error.response; + } else if (error?.data) { + errorInfo.responseData = error.data; + } + + return errorInfo; +} + +/** + * Creates an error tracking transport that wraps the provided base transport. + * It tracks both 4xx/5xx HTTP errors and 2xx responses with error information. + * The URL will be extracted from the error object when available (e.g., from failover transport). + * + * @param baseTransport - The base transport to wrap. + * @returns The error tracking transport. + */ +export const createErrorTrackingTransport = ( + baseTransport: RpcTransport, +): RpcTransport => { + return async ( + ...args: Parameters + ): Promise => { + const { payload } = args[0]; + const { method } = payload as any; + + try { + logger.info(`[🚌 ErrorTrackingTransport] Making RPC request: ${method}`); + + const response = await baseTransport(...args); + + // Check if the response indicates an error (even with 2xx status) + if (isErrorResponse(response)) { + const errorInfo: ErrorTrackingInfo = { + method, + errorMessage: `RPC error in response: ${JSON.stringify(response)}`, + responseData: response, + requestParams: payload, + }; + + await trackError(errorInfo); + + // Returns the response to the caller for direct handling instead of throwing, + // this maintains the original error flow + return response as TResponse; + } + + return response as TResponse; + } catch (error) { + const errorInfo = extractErrorInfo(error, method); + // Track the error + await trackError(errorInfo); + + // And re-throw the original error to maintain the transport chain flow + // If error is not an Error instance, convert it + if (error instanceof Error) { + throw error; + } else { + throw new Error(errorInfo.errorMessage); + } + } + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/failoverTransport.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/failoverTransport.ts new file mode 100644 index 00000000..e0ba302b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/failoverTransport.ts @@ -0,0 +1,49 @@ +import type { RpcTransport } from '@solana/kit'; + +import { noOpLogger } from '../../../utils/logger'; + +const logger = noOpLogger; + +/** + * Creates a failover transport that switches to the next transport on failure. + * It wraps the provided base transports and adds the failover logic. + * + * @param baseTransports - The base transports to use for the failover transport. + * @param urls - The URLs corresponding to each transport (for error tracking). + * @returns The failover transport. + */ +export const createFailoverTransport = + (baseTransports: RpcTransport[], urls: string[]) => + async (...args: Parameters): Promise => { + let lastError; + const transportCount = baseTransports.length; + for (const [index, transport] of baseTransports.entries()) { + try { + const currentUrl = urls[index]; + logger.info( + `[🚌 FailoverTransport] Attempting to use transport ${ + index + 1 + } of ${transportCount} (URL: ${currentUrl})`, + ); + return await transport(...args); + } catch (error) { + const currentUrl = urls[index]; + lastError = error; + const isLastTransport = index === transportCount - 1; + logger.error( + `[🚌 FailoverTransport] Transport failed (URL: ${currentUrl}): ${error}. ${ + isLastTransport + ? 'No more transports to try.' + : 'Trying next transport...' + }`, + ); + + // Adds URL information to the error for better tracking + if (error instanceof Error) { + (error as any).currentUrl = currentUrl; + } + } + } + // If all transports fail, throw the last error. + throw lastError; + }; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/index.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/index.ts new file mode 100644 index 00000000..3801cd15 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/index.ts @@ -0,0 +1 @@ +export { createMainTransport } from './mainTransport'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/mainTransport.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/mainTransport.ts new file mode 100644 index 00000000..84a318a0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/mainTransport.ts @@ -0,0 +1,38 @@ +import { pipe } from '@solana/kit'; + +import { createToggleInfuraBigtableLookupsTransport } from './createToggleInfuraBigtableLookupsTransports'; +import { createErrorTrackingTransport } from './errorTrackingTransport'; +import { createFailoverTransport } from './failoverTransport'; +import { createRetryingTransport } from './retryingTransport'; + +/** + * A functional programming utility to iterate over an array and apply a function to each item. + * + * @param callback - The function to apply to each item. + * @returns An array of the results of applying the function to each item. + */ +const forEach = + (callback: (item: TItem) => TResult) => + (items: TItem[]): TResult[] => + items.map(callback); + +/** + * Creates the main transport for RPC calls, stacking up multiple behaviors: + * + * - Error Tracking: Tracks all RPC errors (4xx, 5xx, and 2xx with errors) using snap_trackError. + * - BigTable Lookups: Toggles BigTable lookups on Infura via the `x-bigtable` header. + * - Failover: Switches to the next transport on failure. + * - Retrying: Retries the request up to MAX_ATTEMPTS times before failing. + * + * @param urls - The URL to use for the transport. + * @returns The transport. + */ +export const createMainTransport = (urls: string[]) => { + return pipe( + urls, + forEach(createToggleInfuraBigtableLookupsTransport), // For each URL, create a transport that toggles BigTable lookups on Infura + (transports) => createFailoverTransport(transports, urls), // Wrap the list of above transports into a single transport that fails over each wrapped transport on failure, passing URLs for error tracking + createRetryingTransport, // Wrap the previous transport into a transport that retries failed requests + (transport) => createErrorTrackingTransport(transport), // Add error tracking as the most outer layer + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/connection/transport/retryingTransport.ts b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/retryingTransport.ts new file mode 100644 index 00000000..ab50cb4e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/connection/transport/retryingTransport.ts @@ -0,0 +1,67 @@ +import type { RpcTransport } from '@solana/kit'; + +import { noOpLogger } from '../../../utils/logger'; +import { sleep } from '../../../utils/sleep'; + +const logger = noOpLogger; + +// Set the maximum number of attempts to retry a request. +const MAX_ATTEMPTS = 4; +const BASE_RETRY_DELAY_MS = 400; +const MAX_RETRY_DELAY_MS = 1500; + +/** + * Calculate the delay for a given attempt. + * + * @param attempt - The attempt number. + * @returns The delay in milliseconds. + */ +function calculateRetryDelay(attempt: number): number { + // Exponential backoff with a maximum delay. + return Math.min( + BASE_RETRY_DELAY_MS * Math.pow(2, attempt), + MAX_RETRY_DELAY_MS, + ); +} + +/** + * Creates a retrying transport that will retry up to MAX_ATTEMPTS times before failing. + * It wraps the provided base transport and adds the retry logic. + * + * @param baseTransport - The base transport to wrap. + * @returns The retrying transport. + */ +export const createRetryingTransport = (baseTransport: RpcTransport) => { + return async ( + ...args: Parameters + ): Promise => { + const { payload } = args[0]; + const { method } = payload as any; + + let requestError; + for (let attempts = 0; attempts < MAX_ATTEMPTS; attempts++) { + try { + logger.info( + `[🚌 RetryingTransport] Attempt "${method}" ${ + attempts + 1 + } of ${MAX_ATTEMPTS}`, + ); + return await baseTransport(...args); + } catch (error) { + logger.error( + `[🚌 RetryingTransport] Error during attempt "${method}" ${ + attempts + 1 + } of ${MAX_ATTEMPTS}: ${error}`, + ); + requestError = error; + // Only sleep if we have more attempts remaining. + if (attempts < MAX_ATTEMPTS - 1) { + const retryDelay = calculateRetryDelay(attempts); + logger.info(`[🚌 RetryingTransport] Sleeping for ${retryDelay}ms`); + await sleep(retryDelay); + } + } + } + throw requestError; + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/index.ts b/merged-packages/solana-wallet-snap/src/core/services/index.ts new file mode 100644 index 00000000..58f2040f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/index.ts @@ -0,0 +1,7 @@ +export * from './accounts'; +export * from './approve-token'; +export * from './assets'; +export * from './send'; +export * from './signer'; +export * from './subscriptions'; +export * from './transactions'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/mocks/logger.ts b/merged-packages/solana-wallet-snap/src/core/services/mocks/logger.ts new file mode 100644 index 00000000..3fd9aa34 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/mocks/logger.ts @@ -0,0 +1,9 @@ +import type { ILogger } from '../../utils/logger'; + +export const mockLogger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as ILogger; diff --git a/merged-packages/solana-wallet-snap/src/core/services/mocks/mockConnection.ts b/merged-packages/solana-wallet-snap/src/core/services/mocks/mockConnection.ts new file mode 100644 index 00000000..03cf41a2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/mocks/mockConnection.ts @@ -0,0 +1,149 @@ +import type { SolanaConnection } from '../connection/SolanaConnection'; +import { + MOCK_MINT_ACCOUNT, + MOCK_SOLANA_RPC_GET_BALANCE_AS_SDK_RESPONSE, + MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_AS_SDK_RESPONSE, + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_AS_SDK_RESPONSE, + MOCK_SOLANA_RPC_GET_TRANSACTION_RESPONSE, + MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE, +} from './mockSolanaRpcResponses'; + +const createMockGetBalance = () => + jest.fn().mockReturnValue({ + send: jest + .fn() + .mockReturnValue(MOCK_SOLANA_RPC_GET_BALANCE_AS_SDK_RESPONSE), + }); + +const createMockGetLatestBlockhash = () => + jest.fn().mockReturnValue({ + send: jest + .fn() + .mockReturnValue(MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_AS_SDK_RESPONSE), + }); + +const createMockSendTransaction = () => + jest.fn().mockReturnValue({ + send: jest + .fn() + .mockReturnValue(MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE.result), + }); + +const createMockGetSignaturesForAddress = () => + jest.fn().mockReturnValue({ + send: jest.fn().mockReturnValue([ + { + signature: + '3B7H4E2ih3Tcas6um1izEBZagVfLoxSUfZSKkSNSu7mh4nAy7ZafaEgKhH4d1NBY2MMRWgyPX2LcMbKYwphR8dRq', + }, + { + signature: + '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + }, + { + signature: + '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + }, + { + signature: + '54Lz5p2zQNU6ngvyGtpeMYEdGoHG2D7ByPS2n3Wa4QNHzqTZ46sUemk1PxSrM6UieQ2i15XiRrTuxZyiPkg8V1vW', + }, + { + signature: + '2a5UXcyb6Gz8DH5MdumBvoGQiHLjTKfPcKrAGcsPrVSUjM9NRVUB1TuL1sNEj59nKBzfLm3Z2RvtsnCGZHa7KXPB', + }, + { + signature: + 'yftYXx1xSmLiMeJ2mGkpZd7Xd13mtW7juWcRnihMhDz1zAeCrq5rPrw7WoCkhEcfUL7MwYCti9Q8bWRdJKZuris', + }, + { + signature: + '24pkWA6oUqtKs1nqx4ZFqW3DoeNcVHC57s1azr63EzaXsDNJAkejmyjB7QonVqvm3cC8cVtbN11jSWTu1xUurQZ9', + }, + { + signature: + '27kCW7f9RCWDkQSqSDrwvbJ3d8mgaFmLLu7GsVujJnp55ue8mQNHvphoVEEF32mXUWZSagdXNraZ7zszBENgAY7T', + }, + { + signature: + '5XpBS9D4bBhc4F69SJd3th19Xe8qhqPyJ3MKWhRLF3tbeHTbSLZSM9UUztJc7pLTASUd2jNR67y2W3Q6LogUnai7', + }, + { + signature: + '5iFQpCwAgiXebzuKxLfhePscR9EYRvRNRSx2Mbj12ed36zNkGmQMkg7ekFXjh88R3p75D6uNK45hgRxC6FyUDnhE', + }, + ]), + }); + +const createMockGetTransaction = () => + jest.fn().mockReturnValue({ + send: jest.fn().mockImplementation(({ signature }) => { + if ( + signature === + '3B7H4E2ih3Tcas6um1izEBZagVfLoxSUfZSKkSNSu7mh4nAy7ZafaEgKhH4d1NBY2MMRWgyPX2LcMbKYwphR8dRq' + ) { + return MOCK_SOLANA_RPC_GET_TRANSACTION_RESPONSE.result; + } + + return null; + }), + }); + +const createMockGetTokenAccountsByOwner = () => + jest.fn().mockReturnValue({ + send: jest + .fn() + .mockReturnValue( + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_AS_SDK_RESPONSE, + ), + }); + +const createMockGetMultipleAccounts = () => + jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue({ value: [] }), + }); + +const createMockSimulateTransaction = () => + jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue({ + value: { + // eslint-disable-next-line id-denylist + err: null, + accounts: null, + logs: [ + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri invoke [1]', + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri consumed 2366 of 1400000 compute units', + 'Program return: 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri KgAAAAAAAAA=', + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri success', + ], + returnData: { + data: ['Kg==', 'base64'], + programId: '83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri', + }, + unitsConsumed: 2366, + }, + }), + }); + +const createMockGetRpc = () => + jest.fn().mockReturnValue({ + getBalance: createMockGetBalance(), + getTokenAccountsByOwner: createMockGetTokenAccountsByOwner(), + getLatestBlockhash: createMockGetLatestBlockhash(), + sendTransaction: createMockSendTransaction(), + getSignaturesForAddress: createMockGetSignaturesForAddress(), + getTransaction: createMockGetTransaction(), + getMultipleAccounts: createMockGetMultipleAccounts(), + simulateTransaction: createMockSimulateTransaction(), + }); + +export const createMockConnection = (): SolanaConnection => + ({ + getRpc: createMockGetRpc(), + fetchMint: jest.fn().mockResolvedValue(MOCK_MINT_ACCOUNT), + getLatestBlockhash: jest + .fn() + .mockResolvedValue( + MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_AS_SDK_RESPONSE.value, + ), + }) as unknown as SolanaConnection; diff --git a/merged-packages/solana-wallet-snap/src/core/services/mocks/mockSolanaRpcResponses.ts b/merged-packages/solana-wallet-snap/src/core/services/mocks/mockSolanaRpcResponses.ts new file mode 100644 index 00000000..46daf9ed --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/mocks/mockSolanaRpcResponses.ts @@ -0,0 +1,533 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import type { Mint } from '@solana-program/token-2022'; +import type { Account, Address, GetLatestBlockhashApi } from '@solana/kit'; +import type { GetBalanceApi } from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; + +/** + * Sample response from the Solana RPC `getBalance` method + * This is the response are received in JSON from the RPC. + * + * The Solana SDK then transforms the response into a {@link ReturnType} object, + * where types slightly differ, see next mock. + */ +export const MOCK_SOLANA_RPC_GET_BALANCE_RESPONSE = { + jsonrpc: '2.0', + result: { + context: { + apiVersion: '1.18.22', + slot: 302900219, + }, + value: 123456789, + }, + id: '0', +}; + +export const MOCK_SOLANA_RPC_GET_BALANCE_AS_SDK_RESPONSE: ReturnType< + GetBalanceApi['getBalance'] +> = { + context: { + slot: 302900219n, + }, + value: lamports(123456789n), +}; + +/** + * Sample response from the Solana RPC `getLatestBlockhash` method + */ +export const MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE = { + jsonrpc: '2.0', + result: { + context: { + apiVersion: '2.0.18', + slot: 346468641, + }, + value: { + blockhash: '8HSvyvQvdRoFkCPnrtqF3dAS4SpPEbMKUVTdrK9auMR', + lastValidBlockHeight: 334650256, + }, + }, + id: '0', +}; + +export const MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_AS_SDK_RESPONSE: ReturnType< + GetLatestBlockhashApi['getLatestBlockhash'] +> = { + context: { + slot: 346468641n, + }, + value: { + blockhash: blockhash('8HSvyvQvdRoFkCPnrtqF3dAS4SpPEbMKUVTdrK9auMR'), + lastValidBlockHeight: 334650256n, + }, +}; + +/** + * Sample response from the Solana RPC `sendTransaction` method + */ +export const MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE = { + jsonrpc: '2.0', + result: { + signature: + '2jy9nsDuajiPgRRijs7Ku4JVvTFQ224Nhoc58fe72tRoy384JAF6zVWFV2SwTt9XXykxes6LkU6VLokn6wAXTocQ', + }, + id: '0', +}; + +/** + * Sample response from the Solana RPC `getFeeForMessage` method + */ +export const MOCK_SOLANA_RPC_GET_FEE_FOR_MESSAGE_RESPONSE = { + jsonrpc: '2.0', + result: { + value: 15000, + }, + id: '0', +}; + +/** + * Sample response from the Solana RPC `simulateTransaction` method + */ +export const MOCK_SOLANA_RPC_SIMULATE_TRANSACTION_RESPONSE = { + jsonrpc: '2.0', + result: { + context: { + slot: 218, + }, + value: { + // eslint-disable-next-line id-denylist + err: null, + accounts: null, + logs: [ + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri invoke [1]', + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri consumed 2366 of 1400000 compute units', + 'Program return: 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri KgAAAAAAAAA=', + 'Program 83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri success', + ], + returnData: { + data: ['Kg==', 'base64'], + programId: '83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri', + }, + unitsConsumed: 2366, + }, + }, + id: 1, +}; + +/** + * Sample response from the Solana RPC `getTokenAccountsByOwner` method + */ +export const MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE = { + jsonrpc: '2.0', + result: { + context: { + slot: 302900219, + }, + value: [ + { + account: { + data: { + parsed: { + info: { + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC + owner: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + isNative: false, + tokenAmount: { + amount: '123456789', + decimals: 6, + uiAmountString: '123.456789', + }, + }, + }, + }, + }, + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + }, + { + account: { + data: { + parsed: { + info: { + mint: 'HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC', // ai16z + owner: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + isNative: false, + tokenAmount: { + amount: '987654321', + decimals: 9, + uiAmountString: '987.654321', + }, + }, + }, + }, + }, + pubkey: 'DJGpJufSnVDriDczovhcQRyxamKtt87PHQ7TJEcVB6ta', + }, + ], + }, + id: '0', +}; + +export const MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_AS_SDK_RESPONSE = { + context: { + slot: 302900219n, + }, + value: MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result.value, +}; + +export const MOCK_SOLANA_RPC_GET_TRANSACTION_RESPONSE = { + jsonrpc: '2.0', + result: { + signature: '123', + }, + id: '0', +}; + +export const MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_RESPONSE = { + jsonrpc: '2.0', + result: { + context: { apiVersion: '2.0.15', slot: 341197247 }, + value: [ + { + data: ['', 'base58'], + executable: false, + lamports: 88849814690250, + owner: '11111111111111111111111111111111', + rentEpoch: 1844674407370955, + space: 0, + }, + { + data: ['', 'base58'], + executable: false, + lamports: 998763433, + owner: '2WRuhE4GJFoE23DYzp2ij6ZnuQ8p9mJeU6gDgfsjR4or', + rentEpoch: 1844674407370955, + space: 0, + }, + ], + }, + id: '0', +}; + +export const MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_SWAP_RESPONSE = { + jsonrpc: '2.0', + id: '0', + result: { + context: { + apiVersion: '2.1.11', + slot: 321903321, + }, + value: [ + { + data: { + parsed: { + info: { + addresses: [ + 'HREZ888X87QTsvKJeAKxtnGUebgoM7ptF1B5jughTADC', + 'DiKDYvWPsxLnP6qM878i26UnqRGHXGqsKe64wra9gqwv', + '6YdD7G2uPRWRie7krnoMmDg1PnMsFMRKNr9keK1wNwJ4', + '2BUu8fyQfyz6B6D8sQDk3CPkKPsJfQDyW5KTcDmj4u7G', + '3pckZriVKu22AT4CBbSbXUvLs5tUDqcrWHYiiF3aDgcs', + '2xegPxVVRQJgje3gPFRPAsw4R27aEGP8C535azD7J1s7', + '8crbKyttXzwFqtB9ptw8M2LXgh497QWzwoaFcbYBrxrr', + 'yQtvBQQwNML1fukwU3BVV7gK8vhpiAxXuWWSrEUn6F1', + 'BiVhEomHTzQsLSBCWJJjyVbLZbTsyz3tXmP3WB1wCCT', + 'AkZfSyBpBJn36Y4DRduoUMKKWMQ6w3idQU1E5ATtFqs7', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'EruBqmvHVLoJdMo3FfFE4dZPL18Q2rkzCv8yuw6DeAk9', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'F6DCuH3ifbfDZVZVNVFcbdjamkvJ7Y796cyw4GpZeUw7', + 'F26B9svY52m7fXgh54wfbFzd2JNX98AE2cTsT7K1JkDc', + '7Vp27DT25hV8Uz89XpYvoNbZwtf9CSmtGgjJjEnL96iV', + 'FqKwBpWSR7HBJLzKLovn69KQAqcNCnhFb1357s1GfwQB', + 'D1ZU2ekM7thxF66ftY6wP2Botfx4toF5L5tqaZj7x3Tg', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'EEnnZ3kqcyuTHCwVVxLzZ6zKLVZcZMaYeyubK4kxHKMi', + 'FRbShKGBpR7YRmdxisbshvhs3Gs93E8fJiVU6sTMNVwu', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'G8baZahwtwtcuzuyG2EjPwyKGrXMVsuwSkXWxRiKtsPY', + 'G9QWhqhYigRoDNcXsekhouX7vxNQMeYp6dcbcjB2AAVp', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '9MceeWigR7f5x28xsM2C95mvY5CLswMxvxgz2iUuQLLB', + 'C6eYeinELaXkGU7RxYrC13YxLmrod4cpwQvSAFqWYLh8', + 'AwW2pD6YjERyXP7XPWYU8KGWXA3pT1EJGXo1an7TUcVD', + '3x5XhDAoGkVwHweVxsTRjy1zT8vstXnTZWUVqxpjm9v4', + 'ZYZTotNXxWVifsngRYv7TfVxHrGgxTbYDPJtJHvUWqH', + '36CJp81kBeBJzkPG5NT1BCLSkBx997KbL5s5yTWokC6B', + 'A4ivspP9X3bi6rdUteDUpm77q1BWfFQRwZoghQ3xW27w', + '4hAfJov7TWFMmse8NsRBGYPwZf5FsDeg7D7CfyFmv1La', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'B81qdKcZSWR3srCiZkwq2X6shhKFsjhMtscgmEAjCWRF', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'SmQhH6u6iYBdSQyRfiPLicGBk2Q3B1oA1fm3vtA693q', + 'DUfugzmPV8XYPheSkMN1V21VVuHS67baB1Z77s4qpA57', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'ByKzZCyWAnh3Q1pStTx2wwfd4U7eXrCgz6SYe25DGcj6', + 'GnGruVf8LAzzFrDes2dLc7K4RunCHhgrjJaaASDKW9zz', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '2uDMzq3sttgeG116wiwQR2hkrh46baqcTzLbhqnDtkze', + 'Hvucz8RFNZ5sg74hbhtp2VkvLkXienUEW78n6PqJWHKn', + 'Hx1HvG22c1MhEPG7bVG5DMCbdPWxzaf5DDJBRhFg136R', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'ChXvtnn5HxijkT1DzmyFpByYRBZMdHAvEVQxuU2a3LW4', + 'ENtGvqetaQn4fMfWJJSBtD5GZKvjtfm3nyzncrtsAJZE', + 'H2ojTAUcg9xPDNXbN2Xbo8VXoj5udUciFhVGQnVpKVDh', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'Gw7PQipXiMn1g5wZPp52u3H7kUoT4GbWDTgWsYpvD1Pe', + '149JGfRzUzzApb9iYxyMDQrnu93gm3yP7ZQhYdekEn67', + '3MSvHTen1aWjMYK6tZ715jHCyC6TJaDHMWhgoToEgYGh', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'HAMdvTSZCGo3x4oG2ujCqYsmYHE7FJNphaicrejuSREp', + 'GmBsYQmhv9vfppUxTX6xwJ6HwnoycZEZMXwjEJzNqHvh', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'J2W4MShfiHSxzz6biHQoc8TQYuGK9cAEzWDp6b78meZ9', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'DPdkaSwjosj9joTqbbmZnU5DuVzadMqng2nzsJW68mz9', + 'FecWqjmfnJHcZSg4mcNcveyBj6cgf13Acxm9BjjaHrs7', + '5v2481hYo3a4KGMqFNcXKw8gdJ8G25z9yAFMAvLzwfkt', + '9YYd6kY3DMXe1xmB8iMHdDkqyAVcudkffPDsbhb2k36M', + '51DJFVzNFUQp9yLynqu9zzQiYc8tqGscdYrxUbcb894k', + '2FYaVDoHw9bESMGz5i1nSgArhLKeZePSL8Wrhrgrby2d', + '6fYQQTgbc1egnTwkiMKwnubipA4umi4wMqffRo6P4Y5k', + 'G3KmxbH8Vt2ewRgkoDhLbKYSwGj1P1Cvm9zKaRLH74rN', + '3NRNgN2ufeqDiM85Zngo7fn79g1v1pS4JGpCz4m7upCj', + 'FpkdNNjnWNuKpsPRMNXxAc1op8JrWEMp86ZUQQuUWMuE', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'FageLLgj7pY9v8k4XW3zxt8qSXnipHh7WEYMaepAvQdG', + 'C1iVk6hC17N5mKDneBx3bgqVLd3YNkUsUk2eHWWbcgTA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '67imZAjqx1ct7e1L53x5m47pvPZ5k9VausYky48H8zor', + 'B3dEApEqveDwbZDR1HVWCG6ngHuzDWbP5KbzwPbpeacX', + 'HDbxPjkxefR4PZeSobVEM5FsDKvQJJWoekuh8hzKcqYC', + '3k7nzqcHgSHVe2dvuSinhvTYrEdTcPsbRXaWUJBcC86f', + '9PBZsS684f986BPJaTinycDt8Cpd2iWZuaLogcABuM5h', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '8iTeSFsvdcuNQtPyern6kQwhAuMs6HyXJD4FnxZRcHds', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'C3jVirjchwhTL5Ems7m5shgLfJb6Lt8AJNasuLKzrtEp', + 'Etk9ydGao3y3woYyg1NZAXMEbABWTjG16N4ypQ5CbEjT', + 'E2CXWLFMQWrJEJgFjtqHDFJ7tfVjVERJH7dxPssuAaH5', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'AzNjPa27Uot7PfY3u5UmiC7v9ZrmwtiY846qavrCK1YQ', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '8a3XLDq3THpwTrn5kXk3ABaxPyC3NHu6dtNiRXoWbpQg', + 'Ew71AgEjxjjrvFx3zNZTZbsdcL4SVAvaewFHtpoTwGiF', + '2dm9LQZN3rrRPHFBrSCGD8JXa4W8ox3XcGmjH9vp5VLB', + 'DRDf42Qcm6K87x3hWVEMAcUa1BrkGSE3bi5fJoNTKbpJ', + '81EUcgVpFUXJD9suqWsNcU9MhNBMVMrKqj8jFfJFm5Wc', + 'ErJ21oGA4iBdADYjUUkpu3DTHZAEYZGPFhGEP9ZXDhhH', + 'HnfKgrRN4V7t8ZEPAe6UuZYRhWETZRRbvqVYp79CRQR9', + 'NQ3mhG7kaa95bpFL1sfQE6JLZANe8cwGc1DyUsLbbN6', + '7ASJW9sKpiWxf2cA1JhSLrLgNncLLXYmPVKWQvMnC74a', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '5L5fopDvMbwfMnbZYcfpQKBmcdBTPjF85Ew1CqevZ5AF', + '6VPkbA5Ptq7qXdnYVTMaAJG7Tbxr5FWseSJwADcu88Q6', + '5YWezNUdxeh569TEyg72V8m4Wr7xmrQzmqai25AXiD5P', + '8i5kBSFuaxrdZRbXvvMPyAdSvZEN3626Yyh67DEKXac8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'D1dFxbLY5mnVwLXEs1cii9U9emDLGzpKC9NP2BSceBak', + 'H1sY1cHZLZgxinbKFm7qCHP5Hq1EpUVadTsADkgCdTsL', + '4Y77dBwxPnebSqhkdSP1WTk732QbzQbLqGdAzmnPYE9b', + '3PwrgzNzpN7jikebZaFvCR7CWmZ1aozifLbgJqLE3Y3o', + '6E7woKnfTuDJvLb82knPgLZFE7j7W3X4j6bgMrvArNNm', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'H2hXrDE7Mok9Kgr1beNafRSbBGUJ6GL1T1pW6WXzqyZa', + '4qdgmwDZDUwiJmqxkpjiVoo61opn24m84FmbTa3s84F8', + 'BdfTDurcZKmGVbdegvrPpiLuDV36PKmeQr5Ag2mdDo4X', + 'AqWp6WmYYgQrBDjBE1MJTXNqNGkjNm1AcYJdiYrXzWE1', + 'DHigkV7cHQoigt2sHMQmMQw3CFHZ14qgD9AHMDSg1pUJ', + '2jdm2JgGvhpd1U6GAchXwHgz41hccWjYVCLFjhrafNHi', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '7xLNmesQAdebcnhrDL4FG7dP1REjHvg733XkcuaxE7Bi', + 'FtUD75JFdvHkKuoMrsKBLGYJ76LC1cZCNNUattXnEnCa', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'DZf1LQzEzJwe2fBRvvDycxiANmeYZFqszf25HA7eKbpM', + '6F4ww5Ghv2okxt1RaU1GHfXR8P7tRjtRP7HGiQdvVrRe', + '8HBqQ4efRwmxk9CPccDz2ahVoTWC5GQsZqnczr29CBQZ', + 'CXUy7n2zf5SeNcJwVC4ektWVGiXTb7XoKpPXRyTyYAX4', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '4N3ZBJdZxuEAy27USBSN5PkH773pR6oee7VxfUgzySB1', + 'GN4KrwyNHM7szw6RLpoPEymFNhvKHcWv9CQJ6NocN9EU', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'BbJiEVd78ZqcDUweNJKBJiTXa4t1jkvU34e9t8XtpCaj', + '24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi', + '9xKXVWtGKhUduWbo3eXvFERXY3JLqaVGJvM5q58Gu5Fj', + '52ZjrBJDGueUBegcdknHZcwa5bnAiJcAJ65hgymxDdRB', + 'Ffbcj42eVwz8fiaQi7KEGbqUcDnK2gw1bY69NihfghM1', + 'G9U1fpzkg4o7ioTX7DaKhnEXuHz8aWRqD6k4ennh4v8H', + 'C2QoQ111jGHEy5918XkNXQro7gGwC9PKLXd1LqBiYNwA', + '3ESUFCnRNgZ7Mn2mPPUMmXYaKU8jpnV9VtA17M7t2mHQ', + 'Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB', + 'Ad2ZNCeJqW8SuJoLTTfiBWX15SZHqNJvz7YWkYysTBxd', + '7pmaHfX11S3tsAC1mCWibDSzCrvnLJYEq6zRegvNSCwC', + 'GsvzF8sg4bxyorwPwKATg3RMqFP8BYPWFe7gimhktfvV', + '3S8MReyVenuGcNaUQtmaXvtpcidrCobugvZ1brDJLFNL', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '3RpEekjLE5cdcG15YcXJUpxSepemvq2FpmMcgo342BwC', + 'FrucERshacaGRC3EFNa7s7naq29GtsMhyaHEXC8gtfL7', + 'So11111111111111111111111111111111111111112', + '4DdDnGj5i9isbAto17Yg4Qm3cWi8u3rZ2qZaKKHx9YVP', + '99MEkgL9dt76RHY6JEgsUkSxqceAb7p6WRVgoTqJHcDh', + 'Bp1VjN2nMxqC8886bNEqcWQc56YjHUwkTriKwprvkN9d', + 'BwDGfzTAswc52ek5UmvNjAFVyZPBcKtSPv99t3tqWZdc', + '8XsBaY5TkKuyVH7CJ9QVQEXfZm7dhUVjsHqjEr4964Qp', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'fEQ8hnNYWavAWa55w6YNx3LjN6Hr34MJebhfUifafSv', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'BWV7LL8qpkzX1jBeh76WCok9iTrQxc1e3ELfoHL2bFoE', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '5LKDPgKQvrQw7fqJMUnDFQ5FehZu55TnatKpkWcFsYXC', + 'Hhvs2Siw1vPpMxV36RTGyJ9xLb14XHkKGzmNrZ1RZwbo', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'FzojgGjMNQa1iZnD31DDvX27rgnu1wEFQEu2szwsVWzs', + '9YnPcoYSXLFWCYF97ZH5BC3vBw2CR3xfDvoM3aK12uHG', + '7Cb2QG9Eo56cQbXtmgGuuxUykVzH38G6GwczQbeQrnjh', + 'AJHkAsdhF4WDV6UaxSVghgDcV4AuNGvTiY9MYr37mjwm', + 'HshSyyBZzsrttdum5pak1pQosNRs8D6u1Qm9bQGy4qtR', + '2KQ3pZPtrt82LgVHMB3wtq4C8fHc1CaFq3aKgpTepbdd', + 'cQ4FTFhRE5ZFKQvYK7YJXf23xi4PqQzZVQo323yQK8s', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '669w3x8jVyxCbH6scwYEVZRsKrwgtTqkrSST3DRSBz1n', + 'uzEaKQYQ9A5uQxLNe5xnajVBheY7d2SSaXTAeGAnAMa', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '2awwFbLKpdD6xkRQgL5iPeyqfwGcVZ6HLZsyEUbVmt4y', + 'G1Bidv3Y2gMYK2CvApHNAZEHxSzQyryen3gTtLE4B3Zo', + 'DtJ7TArT6NC7cKycMqvcHKNDw3y5zDpKAUbWCsew8hCE', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'AxLENVumKMXJePD7PVWjKc4cEUcpn9CnxUgqtoyqNAyP', + 'F3QRbkLL3V4NmtiXBd61N29HmLwb2o6tUzMgSR5CgG3E', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin', + 'FaF5XKRqTNaQ7zXwYNtpig2Q1HArtzJK4xB8XxHERF2j', + 'HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + '8NsPwRFYqob3FzYvHYTjFK6WVFJADFN8Hn7yNQKcVNW1', + '2SgUGxYDczrB6wUzXHPJH65pNhWkEzNMEx3km4xTYUTC', + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + 'J4uBbeoWpZE8fH58PM1Fp9n9K6f1aThyeVCyRdJbaXqt', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4maNZQtYFA1cdB55aLS321dxwdH1Y8NWaH4qiMedKpTZ', + '3f9kSZg8PPJ6NkLwVdXeff16ZT1XbkmT5eaQCqUnpDWx', + 'JAWgN4bAxDcYoMfHNNhMvJcPuqVJUuxRWVbJoPx1QL7E', + '6SiLPJmmdg84g4Jbmp1iorraobhXu6db2nrwmcyxEMC7', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '7bRpEMo6s4GeVyX6wTcKJGYbbauHmCxr9Ptb58Rxg5GX', + 'XoMz8McUzcw2AEHf8tRVT7pEyTznsCtVBpEQMCWGkvg', + '5R7QQV3eCQTahT9muvevrKGe8zHNFnpVg7oHwhRKQNgr', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '6U16UMAd2WBwAYAN3Y7LFUVWirnhRBzHzadhVUgPid31', + '5H2cQkkzuySY6jgrRaJed4T3YSJnWBh6ce3wJcS6knHA', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'FcrhQ9sWKik6n6R56KXjJikzoa9fad9oQ3CHjHztLNHe', + 'H2KJCsVWYz93P55tMQMkY14C67J2BBj32wAAWcWArSv7', + '8xfRcTKyibkSpMd7hGATrytn2nUxShSzJsYiRsCK4RYy', + '8D5E6C9FhB8r3RT3fYToVp631sg2A9AbBEsP9Ubvr9mV', + 'EzPuL3yy2FSXWDtnoeCbn1Lv16QiNAKifRgao82XEYc1', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'EbRxPXvCBNJokgDtyvdcr4pkUM5u9JT8QpyS8EJkgnhh', + 'CK8gqhPfxR95Tsvu7nBaHu2RCBQ8Mvu785cMEenKFWiN', + 'Dn228uL4RwycvrkK5WhMz9Hw3Br5923PBhZAMpzkZxWf', + 'H3X4EbS3K8T8je5fwcW2bffNEW9YcCcAGBPQApGXhiNu', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'rQwq41MsC3m2J1NMPk4n1Y5tN48qcsEnu6ofbx6U9uW', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '7Cy9GodEATUs77vUgkoKjXoGApmh57AUEo9mkE6uaHtk', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '2VAJcCNx2THcHBSdMNT5pTEWnPUdtseZMvm9Cu1oVVSP', + '8cBAopx1ZJL6PaoqXdK2BsC8WzqAxWrUWSAqMjHnLj8f', + 'nH1RDJ7rWnWyeS3kCFUJgVqGLatQhPEMMaLznNQpFcp', + '3tmrC2GQCBxNHNTwYwm2AwLrdkVfxgrpiNgwMhap9Nm4', + 'VyVmemL7RadhEiYPBKuzobwZxWggdqS4ZN6PgthpkZe', + 'BKz84s7gDVWnwoy8CWtbkED7uzBu8DvJCJ9ejGPnsMgj', + '3zmw3hDuL9kX8nMkHmeTdrguDNcCpnQCAkcLCWuGUTHn', + '2HdVCtarXcKavkb5fNMrhAKzxsrbH6GkBryGgMf2BBpw', + '8g7mhDYHF2wXP3DMFSxVuCXyxAhVLcipLQkNoAJQPnw3', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '8zpEKWHCaNLUcRpiYSso3WaBt7QcXsWyjTWpyWRF1CEz', + 'GzwwRGtMcZGTi3o6ea5RU6Hc4gFN1GqYfLxLegmGqKhb', + '9QSg2wBCoSUXCCbkrvTLMa8XDLgaFy21AZPb8pwoibmi', + 'GNSSfmNkdmYs6aBoKkZFVTbGGc9H4jiAyn897CycDnfW', + '8HL86TkcgRB1iX6JNYpCweVeykrAKfhoaTqd1NoobFWq', + '4DjytwhctoApB2CRmKg7MdRrWmTkYfXTRQghsoXxVp7r', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '9kgwxfTrnDitGKcK9werYCVfHKhaKgQXpdiG1KTjLx8s', + '3Vdax95kmeqH8mmAbwgjhLPvWmZQC977zbb3yJ2m9Fpk', + '5uGHgWTJZ836cUJ8AEMjT2gFaLyjEdXXmj5yXuVNyatw', + 'DqFZaFNuFk7qn4DxFRrFgVedqJQDKiwvx9MEzwNo4cMN', + 'AguewXNWtbenJsLp48uMdpWyme3iepvLV9jm5BQc2EW3', + 'AtUWQLBDLnjQVSfBC6zoQJkz2VxZqsebX9HWVnrA7UtE', + 'ESuiLQrTLbTpY1d2TGZMrPsRCs7KWsyofRhg6fL32SqW', + '8wHsyHgbxpt6ZqzSNioV5YaB9iSvWYGWtB2APsFg5NaB', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'GdsTmfYydJx4P4N2ZNNH1yWg1br9JtEt247b1vcYnJK7', + 'E3Bvd17zj3hJPz6UWthJMQV2FhmcCYJNzMP9zSTBFKfo', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4RPfVs7LR3DEvmpx6ZBavLWp12UjsgndC929m7iuDrJP', + ], + authority: '9RAufBfjGQjDfrwxeyKmZWPADHSb8HcoqCdrmpqvCr1g', + deactivationSlot: '18446744073709551615', + lastExtendedSlot: '240115532', + lastExtendedSlotStartIndex: 225, + }, + type: 'lookupTable', + }, + program: 'address-lookup-table', + space: 8216, + }, + executable: false, + lamports: 58179240, + owner: 'AddressLookupTab1e1111111111111111111111111', + rentEpoch: 18446744073709552000, + space: 8216, + }, + ], + }, +}; + +export const MOCK_MINT_ACCOUNT: Account = { + executable: false, + lamports: lamports(390030797122n), + programAddress: TOKEN_PROGRAM_ADDRESS, + space: 82n, + address: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), // Mainnet USDC + data: { + decimals: 6, + isInitialized: true, + supply: 1000000000000000000n, + extensions: { + __option: 'None', + }, + freezeAuthority: { + __option: 'None', + }, + mintAuthority: { + __option: 'None', + }, + }, +}; + +export const MOCK_JSON_PARSED_ACCOUNT: Account = { + executable: false, + lamports: lamports(390030797122n), + programAddress: SYSTEM_PROGRAM_ADDRESS, + space: 82n, + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + data: {}, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/mocks/scanResponses.ts b/merged-packages/solana-wallet-snap/src/core/services/mocks/scanResponses.ts new file mode 100644 index 00000000..ce47dfa8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/mocks/scanResponses.ts @@ -0,0 +1,441 @@ +import type { TransactionScanResult } from '../transaction-scan/types'; + +export const MOCK_SECURITY_ALERTS_API_SCAN_TRANSACTIONS_RESPONSE = { + encoding: 'base58', + status: 'SUCCESS', + error: null, + error_details: null, + result: { + simulation: { + assets_diff: { + '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P': [ + { + asset_type: 'SOL', + asset: { + type: 'SOL', + decimals: 9, + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + in: { + usd_price: 0.98807052604, + summary: 'Gained approximately 0.99$', + value: 0.005669443, + raw_value: 5669443, + }, + out: null, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + symbol: 'COBIE', + name: 'COBIE THE BUILDER', + logo: 'https://ipfs.io/ipfs/QmdWCVHoMvtDCAbgajnzAP7ZBNeBo9RgtkbqE4rJZWGYGq', + type: 'TOKEN', + decimals: 6, + }, + in: null, + out: { + usd_price: null, + summary: 'Lost approximately 202344.646927', + value: 202344.646927, + raw_value: 202344646927, + }, + }, + ], + DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa: [ + { + asset_type: 'SOL', + asset: { + type: 'SOL', + decimals: 9, + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + in: null, + out: { + usd_price: 0.37655198219999997, + summary: 'Lost approximately 0.38$', + value: 0.002160615, + raw_value: 2160615, + }, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + name: 'USD Coin', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + type: 'TOKEN', + decimals: 6, + }, + in: null, + out: { + usd_price: 0.9998669999999998, + summary: 'Lost approximately 1.0$', + value: 1, + raw_value: 1000000, + }, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + symbol: 'COBIE', + name: 'COBIE THE BUILDER', + logo: 'https://ipfs.io/ipfs/QmdWCVHoMvtDCAbgajnzAP7ZBNeBo9RgtkbqE4rJZWGYGq', + type: 'TOKEN', + decimals: 6, + }, + in: { + usd_price: null, + summary: 'Gained approximately 202344.646927', + value: 202344.646927, + raw_value: 202344646927, + }, + out: null, + }, + ], + CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM: [ + { + asset_type: 'SOL', + asset: { + type: 'SOL', + decimals: 9, + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + in: { + usd_price: 0.00988063032, + summary: 'Gained approximately 0.01$', + value: 0.000056694, + raw_value: 56694, + }, + out: null, + }, + ], + J4uBbeoWpZE8fH58PM1Fp9n9K6f1aThyeVCyRdJbaXqt: [ + { + asset_type: 'TOKEN', + asset: { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + name: 'USD Coin', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + type: 'TOKEN', + decimals: 6, + }, + in: { + usd_price: 0.9998669999999998, + summary: 'Gained approximately 1.0$', + value: 1, + raw_value: 1000000, + }, + out: null, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'So11111111111111111111111111111111111111112', + symbol: 'SOL', + name: 'Wrapped SOL', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + type: 'TOKEN', + decimals: 9, + }, + in: null, + out: { + usd_price: 0.9980510188, + summary: 'Lost approximately 1.0$', + value: 0.00572671, + raw_value: 5726710, + }, + }, + ], + }, + assets_ownership_diff: {}, + delegations: {}, + accounts_details: [ + { + type: 'SYSTEM_ACCOUNT', + account_address: 'CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM', + description: null, + was_written_to: true, + }, + { + type: 'SYSTEM_ACCOUNT', + account_address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + description: null, + was_written_to: true, + }, + { + type: 'SYSTEM_ACCOUNT', + account_address: 'Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1', + description: null, + was_written_to: false, + }, + { + type: 'SYSTEM_ACCOUNT', + account_address: 'J4uBbeoWpZE8fH58PM1Fp9n9K6f1aThyeVCyRdJbaXqt', + description: null, + was_written_to: false, + }, + { + type: 'FUNGIBLE_MINT_ACCOUNT', + account_address: 'So11111111111111111111111111111111111111112', + description: 'Wrapped SOL Mint Account', + was_written_to: false, + name: 'Wrapped SOL', + symbol: 'SOL', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + { + type: 'FUNGIBLE_MINT_ACCOUNT', + account_address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + description: 'COBIE THE BUILDER Mint Account', + was_written_to: false, + name: 'COBIE THE BUILDER', + symbol: 'COBIE', + logo: 'https://ipfs.io/ipfs/QmdWCVHoMvtDCAbgajnzAP7ZBNeBo9RgtkbqE4rJZWGYGq', + }, + { + type: 'FUNGIBLE_MINT_ACCOUNT', + account_address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + description: 'USD Coin Mint Account', + was_written_to: false, + name: 'USD Coin', + symbol: 'USDC', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + }, + { + type: 'TOKEN_ACCOUNT', + account_address: '3QE7UkXbrHxEbi373Gp5hwPQwJaAVoLvwYBRz5jwaGxt', + description: "USD Coin's ($USDC) Token Account", + was_written_to: true, + mint_address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + owner_address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + }, + { + type: 'TOKEN_ACCOUNT', + account_address: '3f9kSZg8PPJ6NkLwVdXeff16ZT1XbkmT5eaQCqUnpDWx', + description: "USD Coin's ($USDC) Token Account", + was_written_to: true, + mint_address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + owner_address: 'J4uBbeoWpZE8fH58PM1Fp9n9K6f1aThyeVCyRdJbaXqt', + }, + { + type: 'TOKEN_ACCOUNT', + account_address: '4maNZQtYFA1cdB55aLS321dxwdH1Y8NWaH4qiMedKpTZ', + description: "Wrapped SOL's ($SOL) Token Account", + was_written_to: true, + mint_address: 'So11111111111111111111111111111111111111112', + owner_address: 'J4uBbeoWpZE8fH58PM1Fp9n9K6f1aThyeVCyRdJbaXqt', + }, + { + type: 'TOKEN_ACCOUNT', + account_address: 'Bg55CFRuAfbH5r86Y3UwEdvGZytNYSNKzWkW7jYC7jQU', + description: "COBIE THE BUILDER's ($COBIE) Token Account", + was_written_to: true, + mint_address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + owner_address: 'HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR', + }, + { + type: 'TOKEN_ACCOUNT', + account_address: 'HaMfinLwpZnjntMjbJa1D6WhVyRgZQXYzVCGSVrhG6rD', + description: "COBIE THE BUILDER's ($COBIE) Token Account", + was_written_to: true, + mint_address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + owner_address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + }, + { + type: 'PROGRAM', + account_address: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + description: null, + was_written_to: false, + }, + { + type: 'PROGRAM', + account_address: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + description: null, + was_written_to: false, + }, + { + type: 'PROGRAM', + account_address: '3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br', + description: null, + was_written_to: false, + }, + { + type: 'PROGRAM', + account_address: 'HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + description: null, + was_written_to: false, + }, + { + type: 'PROGRAM', + account_address: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + description: null, + was_written_to: false, + }, + { + type: 'PROGRAM', + account_address: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + description: null, + was_written_to: false, + }, + { + type: 'NATIVE_PROGRAM', + account_address: 'ComputeBudget111111111111111111111111111111', + description: 'Compute Budget', + was_written_to: false, + }, + { + type: 'NATIVE_PROGRAM', + account_address: '11111111111111111111111111111111', + description: 'System Program', + was_written_to: false, + }, + { + type: 'PDA', + account_address: '2SgUGxYDczrB6wUzXHPJH65pNhWkEzNMEx3km4xTYUTC', + description: + 'PDA owned by HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + was_written_to: true, + owner: 'HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + }, + { + type: 'PDA', + account_address: 'HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR', + description: + 'PDA owned by 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + was_written_to: true, + owner: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + }, + { + type: 'PDA', + account_address: '4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf', + description: + 'PDA owned by 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + was_written_to: false, + owner: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + }, + { + type: 'PDA', + account_address: '8NsPwRFYqob3FzYvHYTjFK6WVFJADFN8Hn7yNQKcVNW1', + description: + 'PDA owned by HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + was_written_to: false, + owner: 'HyaB3W9q6XdA5xwpU4XnSZV94htfmbmqJXZcEbRaJutt', + }, + ], + account_summary: { + account_assets_diff: [ + { + asset_type: 'SOL', + asset: { + type: 'SOL', + decimals: 9, + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + }, + in: null, + out: { + usd_price: 0.37655198219999997, + summary: 'Lost approximately 0.38$', + value: 0.002160615, + raw_value: 2160615, + }, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + name: 'USD Coin', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + type: 'TOKEN', + decimals: 6, + }, + in: null, + out: { + usd_price: 0.9998669999999998, + summary: 'Lost approximately 1.0$', + value: 1, + raw_value: 1000000, + }, + }, + { + asset_type: 'TOKEN', + asset: { + address: 'HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump', + symbol: 'COBIE', + name: 'COBIE THE BUILDER', + logo: 'https://ipfs.io/ipfs/QmdWCVHoMvtDCAbgajnzAP7ZBNeBo9RgtkbqE4rJZWGYGq', + type: 'TOKEN', + decimals: 6, + }, + in: { + usd_price: null, + summary: 'Gained approximately 202344.646927', + value: 202344.646927, + raw_value: 202344646927, + }, + out: null, + }, + ], + account_delegations: [], + account_ownerships_diff: [], + total_usd_diff: { + in: 0, + out: 1.3764189821999997, + total: -1.3764189821999997, + }, + }, + }, + validation: { + result_type: 'Benign', + reason: '', + features: [], + extended_features: [], + }, + }, + request_id: 'b731c628-e699-4a71-8a58-d2b578369a44', +}; + +export const MOCK_SCAN_TRANSACTION_RESPONSE: TransactionScanResult = { + status: 'SUCCESS', + estimatedChanges: { + assets: [ + { + type: 'out', + symbol: 'SOL', + name: 'SOL', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png', + value: 0.002160615, + price: 0.37655198219999997, + imageSvg: + '', + }, + { + type: 'out', + symbol: 'USDC', + name: 'USD Coin', + logo: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + value: 1, + price: 0.9998669999999998, + imageSvg: + '', + }, + { + type: 'in', + symbol: 'COBIE', + name: 'COBIE THE BUILDER', + logo: 'https://ipfs.io/ipfs/QmdWCVHoMvtDCAbgajnzAP7ZBNeBo9RgtkbqE4rJZWGYGq', + value: 202344.646927, + price: null, + imageSvg: + '', + }, + ], + }, + validation: { type: 'Benign', reason: 'exposure_farming' }, + error: null, +} as any; diff --git a/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.test.ts new file mode 100644 index 00000000..0c32394f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.test.ts @@ -0,0 +1,214 @@ +import { expect } from '@jest/globals'; +import { + resolveDomain, + getPrimaryDomain, +} from '@solana-name-service/sns-sdk-kit'; +import type { Address } from '@solana/kit'; + +import { Network } from '../../constants/solana'; +import type { SolanaConnection } from '../connection/SolanaConnection'; +import { mockLogger } from '../mocks/logger'; +import { NameResolutionService } from './NameResolutionService'; + +jest.mock('@solana-name-service/sns-sdk-kit'); + +describe('NameResolutionService', () => { + let nameResolutionService: NameResolutionService; + let mockConnection: jest.Mocked; + let mockRpc: any; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock connection + mockConnection = { + getRpc: jest.fn().mockReturnValue(mockRpc), + } as unknown as jest.Mocked; + + // Create service instance + nameResolutionService = new NameResolutionService( + mockConnection, + mockLogger, + ); + }); + + describe('resolveDomain', () => { + const mockDomain = 'example.sol'; + const mockAddress = '11111111111111111111111111111111' as Address; + const mockNetwork = Network.Mainnet; + + beforeEach(() => { + jest.mocked(resolveDomain).mockResolvedValue(mockAddress); + }); + + it('resolves a domain to an address successfully', async () => { + const result = await nameResolutionService.resolveDomain( + mockNetwork, + mockDomain, + ); + + expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork); + expect(resolveDomain).toHaveBeenCalledWith(mockRpc, mockDomain); + expect(result).toBe(mockAddress); + }); + + it('calls getRpc with the correct network', async () => { + await nameResolutionService.resolveDomain(mockNetwork, mockDomain); + + expect(mockConnection.getRpc).toHaveBeenCalledTimes(1); + expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork); + }); + + it('calls resolveDomain with the correct parameters', async () => { + await nameResolutionService.resolveDomain(mockNetwork, mockDomain); + + expect(resolveDomain).toHaveBeenCalledTimes(1); + expect(resolveDomain).toHaveBeenCalledWith(mockRpc, mockDomain); + }); + + it('works with different networks', async () => { + const devnetNetwork = Network.Devnet; + + await nameResolutionService.resolveDomain(devnetNetwork, mockDomain); + + expect(mockConnection.getRpc).toHaveBeenCalledWith(devnetNetwork); + }); + + it('propagates errors from resolveDomain', async () => { + const error = new Error('Domain resolution failed'); + jest.mocked(resolveDomain).mockRejectedValue(error); + + await expect( + nameResolutionService.resolveDomain(mockNetwork, mockDomain), + ).rejects.toThrow('Domain resolution failed'); + }); + + it('works with different domain formats', async () => { + const domains = ['test.sol', 'another.domain', 'sub.domain.test']; + + for (const domain of domains) { + await nameResolutionService.resolveDomain(mockNetwork, domain); + expect(resolveDomain).toHaveBeenCalledWith(mockRpc, domain); + } + }); + }); + + describe('resolveAddress', () => { + const mockAddress = '11111111111111111111111111111111'; + const mockResolvedAddress = '22222222222222222222222222222222'; + const mockDomain = 'example'; + const mockNetwork = Network.Mainnet; + + it('resolves an address to a domain successfully', async () => { + jest.mocked(getPrimaryDomain).mockResolvedValue({ + domainAddress: mockDomain as Address, + domainName: mockDomain, + stale: false, + }); + + const result = await nameResolutionService.resolveAddress( + mockNetwork, + mockAddress, + ); + + expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork); + expect(getPrimaryDomain).toHaveBeenCalledWith(mockRpc, mockAddress); + expect(result).toBe(`${mockDomain}.sol`); + }); + + it('calls getRpc with the correct network', async () => { + await nameResolutionService.resolveAddress(mockNetwork, mockAddress); + + expect(mockConnection.getRpc).toHaveBeenCalledTimes(1); + expect(mockConnection.getRpc).toHaveBeenCalledWith(mockNetwork); + }); + + it('calls getPrimaryDomain with the correct parameters', async () => { + await nameResolutionService.resolveAddress(mockNetwork, mockAddress); + + expect(getPrimaryDomain).toHaveBeenCalledTimes(1); + expect(getPrimaryDomain).toHaveBeenCalledWith(mockRpc, mockAddress); + }); + + it('works with different networks', async () => { + const devnetNetwork = Network.Devnet; + + await nameResolutionService.resolveAddress(devnetNetwork, mockAddress); + + expect(mockConnection.getRpc).toHaveBeenCalledWith(devnetNetwork); + }); + + it('returns null if the primary domain resolution fails', async () => { + jest.mocked(getPrimaryDomain).mockRejectedValue(new Error('Error')); + + expect( + await nameResolutionService.resolveAddress(mockNetwork, mockAddress), + ).toBeNull(); + }); + }); + + describe('integration scenarios', () => { + it('handles both resolveDomain and resolveAddress in sequence', async () => { + const mockDomain = 'example'; + const mockAddress = '11111111111111111111111111111111' as Address; + const mockNetwork = Network.Mainnet; + + // Setup mocks + jest.mocked(resolveDomain).mockResolvedValue(mockAddress); + jest.mocked(getPrimaryDomain).mockResolvedValue({ + domainAddress: mockDomain as Address, + domainName: mockDomain, + stale: false, + }); + + // Test resolveDomain + const resolvedAddress = await nameResolutionService.resolveDomain( + mockNetwork, + mockDomain, + ); + expect(resolvedAddress).toBe(mockAddress); + + // Test resolveAddress + const resolvedDomain = await nameResolutionService.resolveAddress( + mockNetwork, + mockAddress, + ); + expect(resolvedDomain).toBe(`${mockDomain}.sol`); + }); + + it('works with all network types', async () => { + const networks = [ + Network.Mainnet, + Network.Devnet, + Network.Testnet, + Network.Localnet, + ]; + const mockDomain = 'test'; + const mockAddress = '11111111111111111111111111111111' as Address; + + jest.mocked(resolveDomain).mockResolvedValue(mockAddress); + jest.mocked(getPrimaryDomain).mockResolvedValue({ + domainAddress: mockDomain as Address, + domainName: mockDomain, + stale: false, + }); + + for (const network of networks) { + // Test resolveDomain + const resolvedAddress = await nameResolutionService.resolveDomain( + network, + mockDomain, + ); + expect(resolvedAddress).toBe(mockAddress); + + // Test resolveAddress + const resolvedDomain = await nameResolutionService.resolveAddress( + network, + mockAddress, + ); + expect(resolvedDomain).toBe(`${mockDomain}.sol`); + } + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.ts b/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.ts new file mode 100644 index 00000000..7312f80e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/name-resolution/NameResolutionService.ts @@ -0,0 +1,46 @@ +import { + resolveDomain, + getPrimaryDomain, +} from '@solana-name-service/sns-sdk-kit'; +import type { Address } from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { Network } from '../../constants/solana'; +import type { ILogger } from '../../utils/logger'; +import type { SolanaConnection } from '../connection/SolanaConnection'; + +export class NameResolutionService { + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + tld = '.sol'; + + constructor(connection: SolanaConnection, logger: ILogger) { + this.#connection = connection; + this.#logger = logger; + } + + async resolveDomain(scope: Network, domain: string): Promise
{ + const connection = this.#connection.getRpc(scope); + return resolveDomain(connection, domain); + } + + async resolveAddress( + scope: Network, + address: string, + ): Promise { + try { + const connection = this.#connection.getRpc(scope); + const primaryDomain = await getPrimaryDomain( + connection, + asAddress(address), + ); + + return `${primaryDomain.domainName}.sol`; + } catch (error) { + this.#logger.error('Error resolving address', error); + return null; + } + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.test.ts new file mode 100644 index 00000000..28bca9f9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.test.ts @@ -0,0 +1,57 @@ +import type { SolanaConnection } from '../connection'; +import { mockLogger } from '../mocks/logger'; +import { NftService } from './NftService'; + +describe.skip('NftService', () => { + let service: NftService; + let mockConnection: SolanaConnection; + + beforeEach(() => { + mockConnection = { + getRpc: jest.fn().mockReturnValue({ + getAccountInfo: jest.fn().mockReturnValue({ + send: jest.fn(), + }), + }), + } as unknown as SolanaConnection; + + service = new NftService(mockConnection, mockLogger); + }); + + describe('isMaybeNonFungible', () => { + it('returns true for tokens with 0 decimals', () => { + const token = { + tokenAmount: { + decimals: 0, + }, + }; + + const result = NftService.isMaybeNonFungible(token); + expect(result).toBe(true); + }); + + it('returns false for tokens with non-zero decimals', () => { + const token = { + tokenAmount: { + decimals: 6, + }, + }; + + const result = NftService.isMaybeNonFungible(token); + expect(result).toBe(false); + }); + + it('works with various shapes of data, provided it has tokenAmount.decimals', () => { + const token = { + tokenAmount: { + decimals: 0, + amount: '1000', + otherField: 'value', + }, + }; + + const result = NftService.isMaybeNonFungible(token); + expect(result).toBe(true); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.ts b/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.ts new file mode 100644 index 00000000..33417d9d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/nft/NftService.ts @@ -0,0 +1,44 @@ +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import type { SolanaConnection } from '../connection'; + +export class NftService { + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + constructor(connection: SolanaConnection, _logger: ILogger = logger) { + this.#connection = connection; + this.#logger = _logger; + } + + /** + * A quick and synchronous way to check if a token is non-fungible, i.e. it's an NFT. + * + * ⚠️ WARNING: This is NOT a 100% reliable way to check if a token is an NFT, just + * that there's a good chance that the token is an NFT. + * + * It only checks if the token has 0 decimals, which is a common but not exclusive + * characteristic of NFTs. A token with 0 decimals could still be a fungible token + * with a supply greater than 1. + * + * Use cases: + * - Quick filtering of potential NFTs in UI lists. + * - Initial screening before performing full NFT validation. + * - Situations where performance is critical and absolute accuracy isn't required. + * + * @param token - The token account to check. Must contain token amount information. + * @param token.tokenAmount - The token amount object containing decimals information. + * @param token.tokenAmount.decimals - The number of decimal places the token uses. + * @returns True if the token has 0 decimals (potential NFT), false otherwise. + */ + static isMaybeNonFungible< + TToken extends { tokenAmount: { decimals: number } }, + >(token: TToken): boolean { + const { tokenAmount } = token; + const { decimals } = tokenAmount; + + // return decimals === 0; + return false; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/ISendTransactionBuilder.ts b/merged-packages/solana-wallet-snap/src/core/services/send/ISendTransactionBuilder.ts new file mode 100644 index 00000000..e299e9cb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/ISendTransactionBuilder.ts @@ -0,0 +1,25 @@ +import type { Address, CompilableTransactionMessage } from '@solana/kit'; +import type BigNumber from 'bignumber.js'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { Network } from '../../constants/solana'; + +export type BuildSendTransactionParams = { + from: SolanaKeyringAccount; + to: Address; + /** From the user's point of view, this is the uiAmount! Need to divide this by the multiplier if any to get the raw amount */ + amount: string | number | bigint | BigNumber; + network: Network; + mint?: Address; +}; + +/** + * A class that builds transactions for the Send flow. + */ +export type ISendTransactionBuilder = { + buildTransactionMessage( + params: BuildSendTransactionParams, + ): Promise; + getComputeUnitLimit(): number; + getComputeUnitPriceMicroLamportsPerComputeUnit(): bigint; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.test.ts b/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.test.ts new file mode 100644 index 00000000..4f618d16 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.test.ts @@ -0,0 +1,207 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; + +import { Network } from '../../constants/solana'; +import type { SolanaConnection } from '../connection'; +import { mockLogger } from '../mocks/logger'; +import { RecipientClassifier } from './RecipientClassifier'; + +// Mock the @solana/kit functions +jest.mock('@solana/kit', () => ({ + address: jest.fn(), + assertAccountExists: jest.fn(), +})); + +const { assertAccountExists, address: asAddress } = + jest.requireMock('@solana/kit'); + +describe('RecipientClassifier', () => { + let classifier: RecipientClassifier; + let mockConnection: SolanaConnection; + let mockRpc: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRpc = { + getAccountInfo: jest.fn(), + }; + + mockConnection = { + getRpc: jest.fn().mockReturnValue(mockRpc), + fetchJsonParsedAccount: jest.fn(), + } as unknown as SolanaConnection; + + // Mock asAddress to return the input address + asAddress.mockImplementation((address: string) => address); + + assertAccountExists.mockImplementation(() => { + // No error + }); + + classifier = new RecipientClassifier(mockConnection, mockLogger); + }); + + describe('classify', () => { + const recipientAddress = '9iWxPhaTyvUckBA3GqBaa8zfqeyT6UKokJs2MfimYgkr'; + const network = Network.Mainnet; + + it('classifies non-existent account as SYSTEM', async () => { + const mockAccountInfo = null; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo as any); + + assertAccountExists.mockImplementation(() => { + throw new Error('Account does not exist'); + }); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'SYSTEM' }); + expect(mockConnection.fetchJsonParsedAccount).toHaveBeenCalledWith( + recipientAddress, + network, + ); + }); + + it('classifies system program account as SYSTEM', async () => { + const mockAccountInfo = { + programAddress: SYSTEM_PROGRAM_ADDRESS, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'SYSTEM' }); + }); + + it('classifies token program account as TOKEN_ACCOUNT', async () => { + const mintAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const ownerAddress = '9iWxPhaTyvUckBA3GqBaa8zfqeyT6UKokJs2MfimYgkr'; + + const mockAccountInfo = { + programAddress: TOKEN_PROGRAM_ADDRESS, + data: { + mint: mintAddress, + owner: ownerAddress, + }, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ + type: 'TOKEN_ACCOUNT', + mint: mintAddress, + }); + }); + + it('classifies token 2022 program account as TOKEN_ACCOUNT', async () => { + const mintAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const ownerAddress = '9iWxPhaTyvUckBA3GqBaa8zfqeyT6UKokJs2MfimYgkr'; + + const mockAccountInfo = { + programAddress: TOKEN_2022_PROGRAM_ADDRESS, + data: { + mint: mintAddress, + owner: ownerAddress, + }, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ + type: 'TOKEN_ACCOUNT', + mint: mintAddress, + }); + }); + + it('classifies token account without mint as UNSUPPORTED', async () => { + const ownerAddress = '9iWxPhaTyvUckBA3GqBaa8zfqeyT6UKokJs2MfimYgkr'; + + const mockAccountInfo = { + programAddress: TOKEN_PROGRAM_ADDRESS, + data: { + owner: ownerAddress, + // mint is missing + }, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'UNSUPPORTED' }); + }); + + it('classifies token account without owner as UNSUPPORTED', async () => { + const mintAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + const mockAccountInfo = { + programAddress: TOKEN_PROGRAM_ADDRESS, + data: { + mint: mintAddress, + // owner is missing + }, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'UNSUPPORTED' }); + }); + + it('classifies mint account as UNSUPPORTED', async () => { + const mockAccountInfo = { + programAddress: TOKEN_PROGRAM_ADDRESS, + data: { + freezeAuthority: '7dGbd2QZcCKcTndnHcTL8q7SMVXAkp688NTQYwrRCrar', + isInitialized: true, + mintAuthority: 'BJE5MMbqXjVwjAF7oxwPYXnTXDyspzZyt4vwenNw5ruG', + supply: '12807222010705262', + }, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'UNSUPPORTED' }); + }); + + it('classifies program account as UNSUPPORTED', async () => { + const mockAccountInfo = { + programAddress: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + data: {}, + } as any; + + jest + .spyOn(mockConnection, 'fetchJsonParsedAccount') + .mockResolvedValue(mockAccountInfo); + + const result = await classifier.classify(recipientAddress, network); + + expect(result).toStrictEqual({ type: 'UNSUPPORTED' }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.ts b/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.ts new file mode 100644 index 00000000..c3707233 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/RecipientClassifier.ts @@ -0,0 +1,92 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import type { Address } from '@solana/kit'; +import { assertAccountExists } from '@solana/kit'; +import { get } from 'lodash'; + +import type { Network } from '../../constants/solana'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { SolanaConnection } from '../connection'; + +export type RecipientClassification = + | { type: 'SYSTEM' } // System account (wallet) + | { + type: 'TOKEN_ACCOUNT'; + mint: Address; + } + | { type: 'UNSUPPORTED' }; // PDA, metadata, program account, mint, etc. + +/** + * Determines how a recipient address should be handled when sending SPL tokens. + * + * This classifier protects users from accidental fund loss if they enter addresses that are not standard wallet addresses, + * such as token accounts, mints, or program accounts, in the recipient field. + * + * Typically, users provide a wallet (system account) address as the recipient. + * However, it's possible to enter a token account, a mint, or a program-derived address by mistake. + * + * If the address is a token account, the system detects this and transparently adapts the send flow. + * + * The classifier distinguishes among: + * - System account (wallet): sends to the user's main address, via token accounts, created if necessary. + * - Token account: sends directly to the token account. + * - Unsupported account types (PDA, program, mint): rejects the operation. + */ +export class RecipientClassifier { + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + constructor(connection: SolanaConnection, logger: ILogger) { + this.#connection = connection; + this.#logger = createPrefixedLogger(logger, '[👀 RecipientClassifier]'); + } + + async classify( + recipientAddress: string, + network: Network, + ): Promise { + this.#logger.info('Classifying recipient', { recipientAddress, network }); + + // Fetch the recipient account info + const recipientInfo = await this.#connection.fetchJsonParsedAccount( + recipientAddress, + network, + ); + + try { + assertAccountExists(recipientInfo); + } catch (error) { + // The account doesn't exist yet. The blockchain will create it as a system account. + return { type: 'SYSTEM' }; + } + + const { programAddress: recipientProgramAddress } = recipientInfo; + + if (recipientProgramAddress === SYSTEM_PROGRAM_ADDRESS) { + // It's a system account (wallet) + return { type: 'SYSTEM' }; + } + + const recipientMint = get(recipientInfo, 'data.mint'); + const recipientAuthority = get(recipientInfo, 'data.owner'); + + if ( + (recipientProgramAddress === TOKEN_PROGRAM_ADDRESS || + recipientProgramAddress === TOKEN_2022_PROGRAM_ADDRESS) && + recipientMint && + recipientAuthority + ) { + // It's a token account + return { + type: 'TOKEN_ACCOUNT', + mint: recipientMint, + }; + } + + // Anything else is likely a mint account, program data account, metadata, PDA, etc. + return { type: 'UNSUPPORTED' }; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendFeeCalculator.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendFeeCalculator.ts new file mode 100644 index 00000000..99156abd --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendFeeCalculator.ts @@ -0,0 +1,44 @@ +import type { Lamports } from '@solana/kit'; +import { lamports, pipe } from '@solana/kit'; +import BigNumber from 'bignumber.js'; + +import { MICRO_LAMPORTS_PER_LAMPORTS } from '../../constants/solana'; +import type { ISendTransactionBuilder } from './ISendTransactionBuilder'; + +const BASE_FEE = lamports(5000n); + +/** + * Helper class for calculating the fee for a send transaction. + */ +export class SendFeeCalculator { + readonly #builder: ISendTransactionBuilder; + + constructor(builder: ISendTransactionBuilder) { + this.#builder = builder; + } + + #getPriorityFee(): Lamports { + const computeUnitLimit = this.#builder.getComputeUnitLimit(); + const computeUnitPriceMicroLamportsPerComputeUnit = + this.#builder.getComputeUnitPriceMicroLamportsPerComputeUnit(); + + return pipe( + computeUnitLimit, + (value) => BigNumber(value), + (value) => + value.multipliedBy( + computeUnitPriceMicroLamportsPerComputeUnit.toString(), + ), + (value) => + value.dividedToIntegerBy(MICRO_LAMPORTS_PER_LAMPORTS.toString()), + (value) => value.toString(), + BigInt, + lamports, + ); + } + + getFee(): Lamports { + const priorityFee = this.#getPriorityFee(); + return lamports(BASE_FEE + priorityFee); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendService.test.ts new file mode 100644 index 00000000..bf47c833 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendService.test.ts @@ -0,0 +1,572 @@ +import { SolMethod } from '@metamask/keyring-api'; +import { lamports } from '@solana/kit'; + +import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { + KnownCaip19Id, + METAMASK_ORIGIN, + Network, + Networks, +} from '../../constants/solana'; +import { ClientRequestMethod } from '../../handlers/onClientRequest/types'; +import type { SolanaKeyring } from '../../handlers/onKeyringRequest/Keyring'; +import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; +import type { Serializable } from '../../serialization/types'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import type { AssetsService } from '../assets'; +import type { SolanaConnection } from '../connection'; +import { mockLogger } from '../mocks/logger'; +import { createMockConnection } from '../mocks/mockConnection'; +import type { RecipientClassifier } from './RecipientClassifier'; +import { SendService } from './SendService'; +import type { SendSolBuilder } from './SendSolBuilder'; +import type { SendSplTokenBuilder } from './SendSplTokenBuilder'; +import { SendErrorCodes } from './types'; +import type { OnAmountInputRequest, OnConfirmSendRequest } from './types'; + +// Mock dependencies +jest.mock('../../sdk-extensions/codecs'); + +// Mock crypto.randomUUID +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: jest.fn(() => 'test-uuid'), + }, +}); + +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + address: jest.requireActual('@solana/kit').address, + lamports: jest.requireActual('@solana/kit').lamports, + compileTransaction: jest.fn().mockReturnValue({ + message: { + version: 0, + header: { + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 0, + }, + staticAccounts: [], + compiledInstructions: [], + addressTableLookups: [], + }, + }), +})); + +describe('SendService', () => { + let sendService: SendService; + let mockConnection: SolanaConnection; + let mockKeyring: SolanaKeyring; + let mockCache: ICache; + let mockSendSolBuilder: SendSolBuilder; + let mockSendSplTokenBuilder: SendSplTokenBuilder; + let mockAssetsService: AssetsService; + let mockRecipientClassifier: RecipientClassifier; + + const mockAccount: SolanaKeyringAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConnection = createMockConnection(); + mockCache = new InMemoryCache(mockLogger); + + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(lamports(1000000n)), + }), + } as any); + + mockKeyring = { + getAccountOrThrow: jest.fn().mockResolvedValue(mockAccount), + submitRequest: jest.fn().mockResolvedValue({ success: true }), + } as unknown as SolanaKeyring; + + mockRecipientClassifier = { + classify: jest.fn().mockResolvedValue({ type: 'SYSTEM' }), + } as unknown as RecipientClassifier; + + mockSendSolBuilder = { + buildTransactionMessage: jest.fn().mockResolvedValue({} as any), + getComputeUnitLimit: jest.fn().mockReturnValue(200000), + getComputeUnitPriceMicroLamportsPerComputeUnit: jest + .fn() + .mockReturnValue(1000n), + } as unknown as SendSolBuilder; + + mockSendSplTokenBuilder = { + buildTransactionMessage: jest.fn().mockResolvedValue({} as any), + getComputeUnitLimit: jest.fn().mockReturnValue(200000), + getComputeUnitPriceMicroLamportsPerComputeUnit: jest + .fn() + .mockReturnValue(1000n), + } as unknown as SendSplTokenBuilder; + + mockAssetsService = { + findByAccount: jest.fn(), + } as unknown as AssetsService; + + (fromTransactionToBase64String as jest.Mock).mockReturnValue( + 'base64-encoded-tx', + ); + + sendService = new SendService( + mockConnection, + mockKeyring, + mockLogger, + mockCache, + mockRecipientClassifier, + mockSendSolBuilder, + mockSendSplTokenBuilder, + mockAssetsService, + ); + }); + + describe('confirmSend', () => { + const mockRequest: OnConfirmSendRequest = { + jsonrpc: '2.0', + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: mockAccount.id, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '1.5', + assetId: Networks[Network.Mainnet].nativeToken.caip19Id, + }, + }; + + it('confirms native SOL send transaction successfully', async () => { + const result = await sendService.confirmSend(mockRequest); + + expect(mockKeyring.getAccountOrThrow).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockSendSolBuilder.buildTransactionMessage).toHaveBeenCalledWith({ + from: mockAccount, + to: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '1.5', + network: Network.Mainnet, + }); + expect(mockKeyring.submitRequest).toHaveBeenCalledWith({ + id: globalThis.crypto.randomUUID(), + scope: Network.Mainnet, + account: mockAccount.id, + origin: METAMASK_ORIGIN, + request: { + method: SolMethod.SignAndSendTransaction, + params: { + account: { address: mockAccount.address }, + transaction: 'base64-encoded-tx', + scope: Network.Mainnet, + }, + }, + }); + expect(result).toStrictEqual({ success: true }); + }); + + it('confirms SPL token send transaction successfully', async () => { + const tokenRequest: OnConfirmSendRequest = { + ...mockRequest, + params: { + ...mockRequest.params, + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + const result = await sendService.confirmSend(tokenRequest); + + expect( + mockSendSplTokenBuilder.buildTransactionMessage, + ).toHaveBeenCalledWith({ + from: mockAccount, + to: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '1.5', + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }); + expect(result).toStrictEqual({ success: true }); + }); + + it('handles errors from keyring.getAccountOrThrow', async () => { + const error = new Error('Account not found'); + jest.spyOn(mockKeyring, 'getAccountOrThrow').mockRejectedValue(error); + + await expect(sendService.confirmSend(mockRequest)).rejects.toThrow( + 'Account not found', + ); + }); + + it('handles errors from builder.buildTransactionMessage', async () => { + const error = new Error('Failed to build transaction'); + jest + .spyOn(mockSendSolBuilder, 'buildTransactionMessage') + .mockRejectedValue(error); + + await expect(sendService.confirmSend(mockRequest)).rejects.toThrow( + 'Failed to build transaction', + ); + }); + + it('handles errors from keyring.submitRequest', async () => { + const error = new Error('Failed to submit request'); + jest.spyOn(mockKeyring, 'submitRequest').mockRejectedValue(error); + + await expect(sendService.confirmSend(mockRequest)).rejects.toThrow( + 'Failed to submit request', + ); + }); + }); + + describe('onAddressInput', () => { + it('validates valid address successfully', async () => { + const value = MOCK_SOLANA_KEYRING_ACCOUNT_1.address; + const scope = Network.Mainnet; + + const result = await sendService.onAddressInput(value, scope); + + expect(result.valid).toBe(true); + expect(result.errors).toStrictEqual([]); + }); + + it('rejects empty address', async () => { + const value = ''; + const scope = Network.Mainnet; + + const result = await sendService.onAddressInput(value, scope); + + expect(result.valid).toBe(false); + expect(result.errors).toStrictEqual([{ code: SendErrorCodes.Required }]); + }); + + it('rejects invalid address', async () => { + const value = 'invalid-address'; + const scope = Network.Mainnet; + + const result = await sendService.onAddressInput(value, scope); + + expect(result.valid).toBe(false); + expect(result.errors).toStrictEqual([{ code: SendErrorCodes.Invalid }]); + }); + + it('handles null/undefined address', async () => { + const value = null as any; + const scope = Network.Mainnet; + + const result = await sendService.onAddressInput(value, scope); + + expect(result.valid).toBe(false); + expect(result.errors).toStrictEqual([{ code: SendErrorCodes.Invalid }]); + }); + }); + + describe('onAmountInput', () => { + const mockRequest: OnAmountInputRequest = { + jsonrpc: '2.0', + id: '1', + method: ClientRequestMethod.OnAmountInput, + params: { + value: '1.5', + accountId: mockAccount.id, + assetId: Networks[Network.Mainnet].nativeToken.caip19Id, + }, + }; + + const mockAssetBalances: AssetEntity[] = [ + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '10.0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '10000000000', + }, + ]; + + beforeEach(() => { + jest + .spyOn(mockAssetsService, 'findByAccount') + .mockResolvedValue(mockAssetBalances); + + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(lamports(1000000n)), + }), + } as any); + }); + + it('validates valid native SOL amount successfully', async () => { + const result = await sendService.onAmountInput(mockRequest); + + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('rejects empty amount', async () => { + const emptyRequest: OnAmountInputRequest = { + ...mockRequest, + params: { ...mockRequest.params, value: '' }, + }; + + const result = await sendService.onAmountInput(emptyRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Required }], + }); + }); + + it('rejects when asset balance not found', async () => { + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([]); + + await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( + `Balance not found for asset ${mockRequest.params.assetId} and account ${mockAccount.id}`, + ); + }); + + it('rejects when native SOL balance is insufficient for rent exemption', async () => { + const lowBalanceRequest: OnAmountInputRequest = { + ...mockRequest, + params: { ...mockRequest.params, value: '0.000001' }, + }; + + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.00001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '999999999999999999', + }, + ]); + + const result = await sendService.onAmountInput(lowBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }); + + it('rejects when native SOL amount exceeds balance', async () => { + const highAmountRequest: OnAmountInputRequest = { + ...mockRequest, + params: { ...mockRequest.params, value: '15.0' }, // More than 10.0 balance + }; + + const result = await sendService.onAmountInput(highAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + + it('rejects when native SOL amount within balance but insufficient for fees and rent', async () => { + const highAmountRequest: OnAmountInputRequest = { + ...mockRequest, + params: { ...mockRequest.params, value: '9.999999999' }, + }; + + const result = await sendService.onAmountInput(highAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }); + + it('rejects when value is greater than asset balance', async () => { + const zeroBalanceRequest: OnAmountInputRequest = { + ...mockRequest, + params: { + ...mockRequest.params, + assetId: KnownCaip19Id.UsdcMainnet, + value: '0.1', + }, + }; + + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.1', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '10000000000', + }, + { + assetType: KnownCaip19Id.UsdcMainnet, + uiAmount: '0.001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + mint: 'token-address-123', + pubkey: 'token-address-123', + symbol: 'USDC', + decimals: 6, + rawAmount: '1000000', + }, + ]); + + const result = await sendService.onAmountInput(zeroBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + + it('rejects when native SOL balance is zero', async () => { + const zeroSolRequest: OnAmountInputRequest = { + ...mockRequest, + params: { ...mockRequest.params, value: '0.1' }, + }; + + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '0', + }, + ]); + + const result = await sendService.onAmountInput(zeroSolRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + + it('validates SPL token amount successfully', async () => { + const tokenRequest: OnAmountInputRequest = { + ...mockRequest, + params: { + ...mockRequest.params, + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { + assetType: KnownCaip19Id.UsdcMainnet, + uiAmount: '100.0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + mint: 'token-address-123', + pubkey: 'token-address-123', + symbol: 'USDC', + decimals: 6, + rawAmount: '100000000000', + }, + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '1.0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '10000000000', + }, + ]); + + const result = await sendService.onAmountInput(tokenRequest); + + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('rejects SPL token when SOL balance is insufficient for fee and rent', async () => { + const tokenRequest: OnAmountInputRequest = { + ...mockRequest, + params: { + ...mockRequest.params, + assetId: KnownCaip19Id.UsdcMainnet, + }, + }; + + jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + { + assetType: KnownCaip19Id.UsdcMainnet, + uiAmount: '100.0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + mint: 'token-address-123', + pubkey: 'token-address-123', + symbol: 'USDC', + decimals: 6, + rawAmount: '100000000000', + }, + { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.0001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '10000000000', + }, + ]); + + const result = await sendService.onAmountInput(tokenRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }); + + it('handles errors if account is not found', async () => { + const error = new Error('Account not found'); + jest.spyOn(mockKeyring, 'getAccountOrThrow').mockRejectedValue(error); + + await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( + 'Account not found', + ); + }); + + it('handles errors if balances are not found', async () => { + const error = new Error('Failed to fetch balances'); + jest.spyOn(mockAssetsService, 'findByAccount').mockRejectedValue(error); + + await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( + 'Failed to fetch balances', + ); + }); + + it('handles errors if connection fails', async () => { + const error = new Error('RPC connection failed'); + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ + send: jest.fn().mockRejectedValue(error), + }), + } as any); + + await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( + 'RPC connection failed', + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendService.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendService.ts new file mode 100644 index 00000000..cbfea69f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -0,0 +1,331 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { SolMethod } from '@metamask/keyring-api'; +import type { Json } from '@metamask/snaps-sdk'; +import { Duration, parseCaipAssetType } from '@metamask/utils'; +import { address as asAddress, compileTransaction } from '@solana/kit'; +import { BigNumber } from 'bignumber.js'; + +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { Network } from '../../constants/solana'; +import { METAMASK_ORIGIN, Networks } from '../../constants/solana'; +import type { SolanaKeyring } from '../../handlers/onKeyringRequest/Keyring'; +import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; +import type { Serializable } from '../../serialization/types'; +import { solToLamports } from '../../utils/conversion'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { AssetsService } from '../assets'; +import type { SolanaConnection } from '../connection'; +import type { RecipientClassifier } from './RecipientClassifier'; +import { SendFeeCalculator } from './SendFeeCalculator'; +import type { SendSolBuilder } from './SendSolBuilder'; +import type { SendSplTokenBuilder } from './SendSplTokenBuilder'; +import { SendErrorCodes } from './types'; +import type { + OnAmountInputRequest, + OnConfirmSendRequest, + ValidationResponse, +} from './types'; + +export class SendService { + readonly #connection: SolanaConnection; + + readonly #keyring: SolanaKeyring; + + readonly #logger: ILogger; + + readonly #cache: ICache; + + readonly #recipientClassifier: RecipientClassifier; + + readonly #sendSolBuilder: SendSolBuilder; + + readonly #sendSplTokenBuilder: SendSplTokenBuilder; + + readonly #assetsService: AssetsService; + + readonly #cacheTtlsMilliseconds = { + minimumBalanceForRentExemption: 5 * Duration.Minute, + }; + + constructor( + connection: SolanaConnection, + keyring: SolanaKeyring, + logger: ILogger, + cache: ICache, + recipientClassifier: RecipientClassifier, + sendSolBuilder: SendSolBuilder, + sendSplTokenBuilder: SendSplTokenBuilder, + assetsService: AssetsService, + ) { + this.#connection = connection; + this.#keyring = keyring; + this.#cache = cache; + this.#logger = createPrefixedLogger(logger, '[📬 SendService]'); + this.#recipientClassifier = recipientClassifier; + this.#sendSolBuilder = sendSolBuilder; + this.#sendSplTokenBuilder = sendSplTokenBuilder; + this.#assetsService = assetsService; + } + + async #getCachedMinimumBalanceForRentExemption( + scope: Network, + ): Promise { + const minimumBalanceForRentExemption = useCache<[Network], BigNumber>( + async (_scope: Network) => + this.#connection + .getRpc(scope) + .getMinimumBalanceForRentExemption(BigInt(0)) + .send() + .then((response) => BigNumber(response.toString())), + this.#cache, + { + functionName: 'SendService:getMinimumBalanceForRentExemption', + ttlMilliseconds: + this.#cacheTtlsMilliseconds.minimumBalanceForRentExemption, + generateCacheKey: (functionName, args) => { + const [_scope] = args; + return `${functionName}:${_scope}`; + }, + }, + ); + + return minimumBalanceForRentExemption(scope); + } + + /** + * Handles the confirmation of a send transaction. + * + * @param request - The JSON-RPC request containing the parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async confirmSend(request: OnConfirmSendRequest): Promise { + this.#logger.log('Confirming send transaction', request); + + const { fromAccountId, toAddress, amount, assetId } = request.params; + + const account = await this.#keyring.getAccountOrThrow(fromAccountId); + + const { chainId, assetReference } = parseCaipAssetType(assetId); + + const scope = chainId as Network; + + const isNativeSend = assetId === Networks[scope].nativeToken.caip19Id; + + const builder = isNativeSend + ? this.#sendSolBuilder + : this.#sendSplTokenBuilder; + + const params = { + from: account, + to: asAddress(toAddress), + amount, + network: scope, + ...(isNativeSend ? {} : { mint: asAddress(assetReference) }), + }; + + const transactionMessage = await builder.buildTransactionMessage(params); + + const transaction = compileTransaction(transactionMessage); + + const base64EncodedTransaction = fromTransactionToBase64String(transaction); + + const keyringRequest: KeyringRequest = { + id: globalThis.crypto.randomUUID(), + scope, + account: fromAccountId, + origin: METAMASK_ORIGIN, + request: { + method: SolMethod.SignAndSendTransaction, + params: { + account: { + address: account.address, + }, + transaction: base64EncodedTransaction, + scope, + }, + }, + }; + + this.#logger.log('Submitting keyring request', keyringRequest); + + return this.#keyring.submitRequest(keyringRequest); + } + + /** + * Handles the input of an address. + * + * @param value - The value passed in the address input. + * @param scope - The network to validate the address for. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async onAddressInput( + value: string, + scope: Network, + ): Promise { + if (value === '') { + return { + valid: false, + errors: [{ code: SendErrorCodes.Required }], + }; + } + + try { + asAddress(value); + + // Check if the address is a supported recipient + const recipientClassification = await this.#recipientClassifier.classify( + value, + scope, + ); + if (recipientClassification.type === 'UNSUPPORTED') { + throw new Error('Unsupported recipient'); + } + + return { + valid: true, + errors: [], + }; + } catch (error) { + return { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }; + } + } + + /** + * Handles the input of an amount. + * + * Covers the following errors: + * - Required: If the value is empty. + * - InsufficientSolToCoverFee: If the value is less than the minimum balance for rent exemption. + * - InsufficientBalance: If the value is greater than the balance. + * + * @param request - The JSON-RPC request containing the parameters. + * @returns The response to the JSON-RPC request. + */ + async onAmountInput( + request: OnAmountInputRequest, + ): Promise { + const { + params: { value, accountId, assetId }, + } = request; + + const account = await this.#keyring.getAccountOrThrow(accountId); + + const { chainId } = parseCaipAssetType(assetId); + + const scope = chainId as Network; + + const nativeAssetType = Networks[scope].nativeToken.caip19Id; + + const isNativeToken = assetId === nativeAssetType; + + const accountBalances = await this.#assetsService.findByAccount(account); + + const assetEntry = accountBalances.find( + (asset) => asset.assetType === assetId, + ); + + const nativeAsset = accountBalances.find( + (asset) => asset.assetType === nativeAssetType, + ); + + if (!assetEntry) { + throw new Error( + `Balance not found for asset ${assetId} and account ${accountId}`, + ); + } + + // 1. value is required + if (value === '') { + return { + valid: false, + errors: [{ code: SendErrorCodes.Required }], + }; + } + + const assetBalanceLamports = solToLamports(assetEntry.uiAmount ?? '0'); + const nativeBalanceLamports = solToLamports(nativeAsset?.uiAmount ?? '0'); + const valueLamports = solToLamports(value); + + const minimumBalanceForRentExemptionLamports = + await this.#getCachedMinimumBalanceForRentExemption(scope); + + const builder = isNativeToken + ? this.#sendSolBuilder + : this.#sendSplTokenBuilder; + + const sendFeeCalculator = new SendFeeCalculator(builder); + const feeInLamports = sendFeeCalculator.getFee(); + const feeEstimatedInLamports = BigNumber(feeInLamports.toString()); + + // If you have 0 SOL, you can't pay for the fee, it's invalid + if (nativeBalanceLamports.isZero()) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + if (isNativeToken) { + // First check if the amount alone exceeds the native balance + if (valueLamports.gt(nativeBalanceLamports)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + // Then check if the (amount + fee + minimum balance for rent exemption) is greater than the native balance + const isAmountPlusFeePlusRentExemptionGreaterThanBalance = valueLamports + .plus(feeEstimatedInLamports) + .plus(minimumBalanceForRentExemptionLamports) + .gt(nativeBalanceLamports); + + if (isAmountPlusFeePlusRentExemptionGreaterThanBalance) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + } else { + // For SPL tokens, we need to ensure the native SOL balance can cover: + // 1. Transaction fees + // 2. Minimum balance for rent exemption (for potential ATA creation) + + // Check if the amount is greater than the asset balance + const isAmountGreaterThanAssetBalance = + valueLamports.gt(assetBalanceLamports); + + if (isAmountGreaterThanAssetBalance) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + // Check if the native SOL balance can cover fees + minimum rent exemption + const isFeePlusRentExemptionGreaterThanNativeBalance = + feeEstimatedInLamports + .plus(minimumBalanceForRentExemptionLamports) + .gt(nativeBalanceLamports); + + if (isFeePlusRentExemptionGreaterThanNativeBalance) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + } + + return { + valid: true, + errors: [], + }; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.test.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.test.ts new file mode 100644 index 00000000..af461ceb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.test.ts @@ -0,0 +1,91 @@ +import { address } from '@solana/kit'; +import BigNumber from 'bignumber.js'; + +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNTS } from '../../test/mocks/solana-keyring-accounts'; +import logger from '../../utils/logger'; +import { createMockConnection } from '../mocks/mockConnection'; +import { SendSolBuilder } from './SendSolBuilder'; + +describe('SendSolBuilder', () => { + const mockConnection = createMockConnection(); + + const mockFrom = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + const mockTo = address(MOCK_SOLANA_KEYRING_ACCOUNTS[1].address); + const mockAmount = BigNumber(1); // 1 SOL + const mockNetwork = Network.Testnet; + + let sendSolBuilder: SendSolBuilder; + + beforeEach(() => { + jest.clearAllMocks(); + sendSolBuilder = new SendSolBuilder(mockConnection, logger); + }); + + describe('buildTransactionMessage', () => { + it('successfully builds transaction message', async () => { + const transactionMessage = await sendSolBuilder.buildTransactionMessage({ + from: mockFrom, + to: mockTo, + amount: mockAmount, + network: mockNetwork, + }); + + expect(transactionMessage).toStrictEqual({ + instructions: [ + { + programAddress: 'ComputeBudget111111111111111111111111111111', + data: new Uint8Array([2, 194, 1, 0, 0]), + }, + { + programAddress: 'ComputeBudget111111111111111111111111111111', + data: new Uint8Array([3, 16, 39, 0, 0, 0, 0, 0, 0]), + }, + { + accounts: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + role: 3, + signer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + signMessages: expect.any(Function), + signTransactions: expect.any(Function), + }, + }, + { + address: 'FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo', + role: 1, + }, + ], + programAddress: '11111111111111111111111111111111', + data: new Uint8Array([2, 0, 0, 0, 0, 202, 154, 59, 0, 0, 0, 0]), + }, + ], + version: 0, + feePayer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }, + lifetimeConstraint: { + blockhash: '8HSvyvQvdRoFkCPnrtqF3dAS4SpPEbMKUVTdrK9auMR', + lastValidBlockHeight: 334650256n, + }, + }); + }); + + it('throws error when building message fails', async () => { + const mockError = new Error('Failed to fetch blockhash'); + jest + .spyOn(mockConnection, 'getLatestBlockhash') + .mockRejectedValue(mockError); + + await expect( + sendSolBuilder.buildTransactionMessage({ + from: mockFrom, + to: mockTo, + amount: mockAmount, + network: mockNetwork, + }), + ).rejects.toThrow(mockError); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.ts new file mode 100644 index 00000000..66f9a5e5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.ts @@ -0,0 +1,113 @@ +import { + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, +} from '@solana-program/compute-budget'; +import { getTransferSolInstruction } from '@solana-program/system'; +import type { CompilableTransactionMessage } from '@solana/kit'; +import { + address, + appendTransactionMessageInstruction, + createNoopSigner, + createTransactionMessage, + pipe, + prependTransactionMessageInstructions, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, +} from '@solana/kit'; + +import { solToLamports } from '../../utils/conversion'; +import type { ILogger } from '../../utils/logger'; +import type { SolanaConnection } from '../connection'; +import type { + BuildSendTransactionParams, + ISendTransactionBuilder, +} from './ISendTransactionBuilder'; + +/** + * Helper class for transferring SOL. + */ +export class SendSolBuilder implements ISendTransactionBuilder { + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + // The send SOL transaction has a predictable compute unit consumption of 450. + readonly #computeUnitLimit = 450; + + readonly #computeUnitPriceMicroLamportsPerComputeUnit = 10000n; + + constructor(connection: SolanaConnection, logger: ILogger) { + this.#connection = connection; + this.#logger = logger; + } + + /** + * Build the transaction message for transferring SOL. + * + * @param params - The parameters for building the transaction message. + * @returns The transaction message. + */ + async buildTransactionMessage( + params: BuildSendTransactionParams, + ): Promise { + try { + const { from, to, amount, network } = params; + const fromAddress = address(from.address); + + const amountInLamports = BigInt(solToLamports(amount).toString()); + + const latestBlockhash = + await this.#connection.getLatestBlockhash(network); + + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + // Every transaction must state from which account the transaction fee should be debited from, + // and that account must sign the transaction. Here, we'll make the source account pay the fee. + (tx) => setTransactionMessageFeePayer(fromAddress, tx), + // A transaction is valid for execution as long as it includes a valid lifetime constraint. Here + // we supply the hash of a recent block. The network will accept this transaction until it + // considers that hash to be 'expired' for the purpose of transaction execution. + (tx) => + setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + // Every transaction needs at least one instruction. This instruction describes the transfer. + (tx) => + appendTransactionMessageInstruction( + /** + * The system program has the exclusive right to transfer Lamports from one account to + * another. Here we use an instruction creator from the `@solana-program/system` package + * to create a transfer instruction for the system program. + */ + getTransferSolInstruction({ + amount: amountInLamports, + destination: to, + source: createNoopSigner(fromAddress), + }), + tx, + ), + ); + + const budgetedTransactionMessage = prependTransactionMessageInstructions( + [ + getSetComputeUnitLimitInstruction({ units: this.#computeUnitLimit }), + getSetComputeUnitPriceInstruction({ + microLamports: this.#computeUnitPriceMicroLamportsPerComputeUnit, + }), + ], + transactionMessage, + ); + + return budgetedTransactionMessage; + } catch (error) { + this.#logger.error({ error }, 'Error building transaction message'); + throw error; + } + } + + getComputeUnitLimit(): number { + return this.#computeUnitLimit; + } + + getComputeUnitPriceMicroLamportsPerComputeUnit(): bigint { + return this.#computeUnitPriceMicroLamportsPerComputeUnit; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.test.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.test.ts new file mode 100644 index 00000000..8b6690f7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.test.ts @@ -0,0 +1,322 @@ +/* eslint-disable no-restricted-globals */ + +import type { Mint } from '@solana-program/token-2022'; +/* eslint-disable @typescript-eslint/no-require-imports */ +import { address, lamports } from '@solana/kit'; +import type { Account } from '@solana/kit'; +import type { Address } from '@solana/kit'; +import { cloneDeep } from 'lodash'; + +import type { RecipientClassifier } from '..'; +import { TokenHelper } from '..'; +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNTS } from '../../test/mocks/solana-keyring-accounts'; +import type { SolanaConnection } from '../connection/SolanaConnection'; +import { mockLogger } from '../mocks/logger'; +import { createMockConnection } from '../mocks/mockConnection'; +import { MOCK_MINT_ACCOUNT } from '../mocks/mockSolanaRpcResponses'; +import { RecipientUnsupportedError } from './errors'; +import { SendSplTokenBuilder } from './SendSplTokenBuilder'; + +// Mock the deriveSolanaKeypair function +jest.mock('../../../core/utils/deriveSolanaKeypair', () => ({ + deriveSolanaKeypair: jest.fn(), +})); + +describe('SendSplTokenBuilder', () => { + let mockTokenHelper: TokenHelper; + let mockRecipientClassifier: RecipientClassifier; + let mockConnection: SolanaConnection; + let sendSplTokenBuilder: SendSplTokenBuilder; + + const mockFrom = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + const mockTo = address(MOCK_SOLANA_KEYRING_ACCOUNTS[1].address); + const mockMint = MOCK_MINT_ACCOUNT.address; + const mockNetwork = Network.Localnet; + const mockAmount = '1000'; + const mockAmountLamports = lamports(1000n * 10n ** 6n); + + const createMockMintAccount = (): Account => + cloneDeep(MOCK_MINT_ACCOUNT); + + beforeEach(() => { + jest.clearAllMocks(); + + // Set up the mock implementation + const { deriveSolanaKeypair } = require('../../utils/deriveSolanaKeypair'); + const { + deriveSolanaKeypairMock, + } = require('../../test/mocks/utils/deriveSolanaKeypair'); + deriveSolanaKeypair.mockImplementation(deriveSolanaKeypairMock); + + mockConnection = createMockConnection(); + + mockTokenHelper = new TokenHelper(mockConnection); + + mockRecipientClassifier = { + classify: jest.fn().mockResolvedValue({ type: 'SYSTEM' }), + } as unknown as RecipientClassifier; + + jest + .spyOn(mockTokenHelper, 'uiAmountToAmountForMint') + .mockResolvedValue(mockAmountLamports); + + sendSplTokenBuilder = new SendSplTokenBuilder( + mockTokenHelper, + mockRecipientClassifier, + mockConnection, + mockLogger, + ); + }); + + describe('buildTransactionMessage', () => { + it('successfully builds a transaction message for SPL token transfer to a system account', async () => { + const mockMintAccount = createMockMintAccount(); + jest + .spyOn(mockConnection, 'fetchMint') + .mockResolvedValue(mockMintAccount); + + // Mock deriveAssociatedTokenAccountAddress (static method) + const deriveAssociatedTokenAccountAddressSpy = jest.spyOn( + SendSplTokenBuilder, + 'deriveAssociatedTokenAccountAddress', + ); + deriveAssociatedTokenAccountAddressSpy + .mockResolvedValueOnce('fromTokenAccountAddress' as Address) // from + .mockResolvedValueOnce('toTokenAccountAddress' as Address); // to + + const transactionMessage = + await sendSplTokenBuilder.buildTransactionMessage({ + from: mockFrom, + to: mockTo, + mint: mockMint, + amount: mockAmount, + network: mockNetwork, + }); + + // Verify the transaction message + expect(transactionMessage).toStrictEqual({ + version: 0, + feePayer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }, + lifetimeConstraint: { + blockhash: '8HSvyvQvdRoFkCPnrtqF3dAS4SpPEbMKUVTdrK9auMR', + lastValidBlockHeight: 334650256n, + }, + instructions: [ + { + data: new Uint8Array([2, 64, 156, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + data: new Uint8Array([3, 16, 39, 0, 0, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + // Instruction CreateAssociatedTokenIdempotent + { + accounts: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + role: 3, + signer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + keyPair: { + privateKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: false, + type: 'private', + usages: ['sign'], + }), + publicKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: true, + type: 'public', + usages: ['verify'], + }), + }, + signMessages: expect.any(Function), + signTransactions: expect.any(Function), + }, + }, + { + address: 'toTokenAccountAddress', + role: 1, + }, + { + address: 'FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo', + role: 0, + }, + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + role: 0, + }, + { + address: '11111111111111111111111111111111', + role: 0, + }, + { + address: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + role: 0, + }, + ], + data: Uint8Array.from([1]), + programAddress: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', // Create + }, + // Instruction TransferChecked + { + accounts: [ + { + address: 'fromTokenAccountAddress', + role: 1, + }, + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + role: 0, + }, + { + address: 'toTokenAccountAddress', + role: 1, + }, + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + role: 2, + signer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + keyPair: { + privateKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: false, + type: 'private', + usages: ['sign'], + }), + publicKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: true, + type: 'public', + usages: ['verify'], + }), + }, + signMessages: expect.any(Function), + signTransactions: expect.any(Function), + }, + }, + ], + data: Uint8Array.from([12, 0, 202, 154, 59, 0, 0, 0, 0, 6]), + programAddress: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + ], + }); + + // Restore the static method spy + deriveAssociatedTokenAccountAddressSpy.mockRestore(); + }); + + it('successfully builds a transaction message for SPL token transfer to a token account', async () => { + jest + .spyOn(mockRecipientClassifier, 'classify') + .mockResolvedValue({ type: 'TOKEN_ACCOUNT', mint: mockMint }); + + const transactionMessage = + await sendSplTokenBuilder.buildTransactionMessage({ + from: mockFrom, + to: mockTo, + mint: mockMint, + amount: mockAmount, + network: mockNetwork, + }); + + expect(transactionMessage).toStrictEqual({ + version: 0, + feePayer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }, + lifetimeConstraint: { + blockhash: '8HSvyvQvdRoFkCPnrtqF3dAS4SpPEbMKUVTdrK9auMR', + lastValidBlockHeight: 334650256n, + }, + instructions: [ + { + data: new Uint8Array([2, 64, 156, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + { + data: new Uint8Array([3, 16, 39, 0, 0, 0, 0, 0, 0]), + programAddress: 'ComputeBudget111111111111111111111111111111', + }, + // Note that there's no CreateAssociatedTokenIdempotent because the recipient is a token account + // Instruction TransferChecked + { + accounts: [ + { + address: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + role: 1, + }, + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + role: 0, + }, + { + address: 'FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo', + role: 1, + }, + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + role: 2, + signer: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + keyPair: { + privateKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: false, + type: 'private', + usages: ['sign'], + }), + publicKey: expect.objectContaining({ + algorithm: { + name: 'Ed25519', + }, + extractable: true, + type: 'public', + usages: ['verify'], + }), + }, + signMessages: expect.any(Function), + signTransactions: expect.any(Function), + }, + }, + ], + data: new Uint8Array([12, 0, 202, 154, 59, 0, 0, 0, 0, 6]), + programAddress: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + ], + }); + }); + + it('throws an error if the recipient is unsupported', async () => { + jest + .spyOn(mockRecipientClassifier, 'classify') + .mockResolvedValue({ type: 'UNSUPPORTED' }); + + await expect( + sendSplTokenBuilder.buildTransactionMessage({ + from: mockFrom, + to: mockTo, + mint: mockMint, + amount: mockAmount, + network: mockNetwork, + }), + ).rejects.toThrow(RecipientUnsupportedError); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.ts b/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.ts new file mode 100644 index 00000000..833dbbfd --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/SendSplTokenBuilder.ts @@ -0,0 +1,267 @@ +import { assert } from '@metamask/utils'; +import { + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, +} from '@solana-program/compute-budget'; +import { + findAssociatedTokenPda, + getCreateAssociatedTokenIdempotentInstruction, + getTransferCheckedInstruction, +} from '@solana-program/token'; +import { + getCreateAssociatedTokenIdempotentInstruction as getCreateAssociatedTokenIdempotentInstruction2022, + getTransferCheckedInstruction as getTransferCheckedInstruction2022, + TOKEN_2022_PROGRAM_ADDRESS, +} from '@solana-program/token-2022'; +import { + appendTransactionMessageInstructions, + createKeyPairSignerFromPrivateKeyBytes, + createTransactionMessage, + pipe, + prependTransactionMessageInstructions, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, +} from '@solana/kit'; +import type { CompilableTransactionMessage, IInstruction } from '@solana/kit'; +import type { Address } from '@solana/kit'; + +import type { TokenHelper } from '..'; +import { deriveSolanaKeypair } from '../../utils/deriveSolanaKeypair'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { SolanaConnection } from '../connection'; +import { + RecipientTokenAccountMintMismatchError, + RecipientUnsupportedError, +} from './errors'; +import type { + BuildSendTransactionParams, + ISendTransactionBuilder, +} from './ISendTransactionBuilder'; +import type { RecipientClassifier } from './RecipientClassifier'; + +export class SendSplTokenBuilder implements ISendTransactionBuilder { + readonly #tokenHelper: TokenHelper; + + readonly #recipientClassifier: RecipientClassifier; + + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + /** + * The transaction built here consumes up to ~30,000 compute units when just transferring + * to an existing associated token account, but requires ~35,000+ compute units when + * creating the recipient's associated token account. + */ + readonly #computeUnitLimit = 40_000; + + readonly #computeUnitPriceMicroLamportsPerComputeUnit = 10000n; + + constructor( + tokenHelper: TokenHelper, + recipientClassifier: RecipientClassifier, + connection: SolanaConnection, + logger: ILogger, + ) { + this.#tokenHelper = tokenHelper; + this.#recipientClassifier = recipientClassifier; + this.#connection = connection; + this.#logger = createPrefixedLogger(logger, '[📩 SendSplTokenBuilder]'); + } + + async buildTransactionMessage( + params: BuildSendTransactionParams, + ): Promise { + this.#logger.log('Build transfer SPL token transaction message'); + + const { + from: { entropySource, derivationPath }, + to: recipientAddress, // Use a local alias because "recipientAddress" is more expressive + mint, + amount, + network, + } = params; + assert(mint, 'Mint is required'); + + /** + * Classify the recipient. Depending on whether it's a system account or a token account, + * we will need to build a transaction with different instructions. + */ + const recipientClassification = await this.#recipientClassifier.classify( + recipientAddress, + network, + ); + + // Early-return to prevent the send if the recipient is unsupported to avoid permanent loss of funds + if (recipientClassification.type === 'UNSUPPORTED') { + throw new RecipientUnsupportedError(); + } + + // These can be parallelized for performance + const [ + mintAccount, + rawAmountInLamports, + { privateKeyBytes }, + latestBlockhash, + ] = await Promise.all([ + this.#connection.fetchMint(mint, network), + this.#tokenHelper.uiAmountToAmountForMint( + mint, + network, + amount.toString(), + ), + deriveSolanaKeypair({ entropySource, derivationPath }), + this.#connection.getLatestBlockhash(network), + ]); + + const { + programAddress: tokenProgram, + data: { decimals }, + } = mintAccount; + + const signer = + await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); + + // Derive the senders's Associated Token Account (ATA) address. Tokens will be sent from here + const fromATA = + await SendSplTokenBuilder.deriveAssociatedTokenAccountAddress({ + mint, + owner: signer.address, + tokenProgram, + }); + + const instructions: IInstruction[] = []; + + // Use the Token-2022 instructions if the token is a Token-2022 token + const isToken2022 = tokenProgram === TOKEN_2022_PROGRAM_ADDRESS; + const createAtaInstruction = isToken2022 + ? getCreateAssociatedTokenIdempotentInstruction2022 + : getCreateAssociatedTokenIdempotentInstruction; + const transferInstruction = isToken2022 + ? getTransferCheckedInstruction2022 + : getTransferCheckedInstruction; + + switch (recipientClassification.type) { + // If the recipient is a system account, we send the tokens to the related ATA + case 'SYSTEM': { + // Derive the recipient's ATA address + const recipientATA = + await SendSplTokenBuilder.deriveAssociatedTokenAccountAddress({ + mint, + owner: recipientAddress, + tokenProgram, + }); + + // Add an instruction that creates the recipient's ATA if it doesn't exist + instructions.push( + createAtaInstruction({ + mint, + payer: signer, + tokenProgram, + owner: recipientAddress, + ata: recipientATA, + }), + ); + + // Add an instruction that transfers the tokens from the sender's ATA to the recipient's ATA + instructions.push( + transferInstruction({ + source: fromATA, + mint, + destination: recipientATA, + authority: signer, + amount: rawAmountInLamports, + decimals, + }), + ); + break; + } + // If the recipient is a token account, we send the tokens to it directly + case 'TOKEN_ACCOUNT': { + // Verify that the recipient's token account is for the same mint as the one we intend to send + if (recipientClassification.mint !== mint) { + throw new RecipientTokenAccountMintMismatchError(); + } + + // Add an instruction that transfers the tokens from the sender's ATA to the recipient's token account + instructions.push( + transferInstruction({ + source: fromATA, + mint, + destination: recipientAddress, + authority: signer, + amount: rawAmountInLamports, + decimals, + }), + ); + break; + } + // Prevent the send if the recipient is unsupported to avoid permanent loss of funds + default: + throw new RecipientUnsupportedError(); + } + + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayer(signer.address, tx), + (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + (tx) => appendTransactionMessageInstructions(instructions, tx), + ); + + const budgetedTransactionMessage = prependTransactionMessageInstructions( + [ + getSetComputeUnitLimitInstruction({ units: this.#computeUnitLimit }), + getSetComputeUnitPriceInstruction({ + microLamports: this.#computeUnitPriceMicroLamportsPerComputeUnit, + }), + ], + transactionMessage, + ); + + return budgetedTransactionMessage; + } + + /** + * Derive the associated token account address for a given mint and owner. + * + * @param params - + * @param params.mint - The mint address. + * @param params.owner - The owner's address. + * @param params.tokenProgram - The token program to use. If not provided, it will be determined automatically. + * @returns The associated token account address. + */ + static async deriveAssociatedTokenAccountAddress({ + mint, + owner, + tokenProgram, + }: { + mint: Address; + owner: Address; + tokenProgram: Address; + }): Promise
{ + return ( + await findAssociatedTokenPda({ + mint, + owner, + tokenProgram, + }) + )[0]; + } + + getComputeUnitLimit(): number { + return this.#computeUnitLimit; + } + + getComputeUnitPriceMicroLamportsPerComputeUnit(): bigint { + return this.#computeUnitPriceMicroLamportsPerComputeUnit; + } +} + +export type Exists = { + readonly exists: true; +}; + +export type MaybeHasDecimals = { + decimals?: number | undefined | null; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/errors.ts b/merged-packages/solana-wallet-snap/src/core/services/send/errors.ts new file mode 100644 index 00000000..a410bda0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/errors.ts @@ -0,0 +1,34 @@ +const PERMANENT_LOSS_OF_FUNDS_MESSAGE = + 'Sending to this address may result in permanent loss of funds.'; + +const VALID_RECIPIENT_MESSAGE = + 'Only wallet addresses and token accounts are supported.'; + +export class RecipientClassificationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'RecipientClassificationError'; + } +} + +export class RecipientUnsupportedError extends RecipientClassificationError { + constructor() { + super( + `Cannot send to this address. This appears to be a mint account, program data account, metadata account, or PDA. ${ + PERMANENT_LOSS_OF_FUNDS_MESSAGE + } ${VALID_RECIPIENT_MESSAGE}`, + ); + this.name = 'RecipientUnsupportedError'; + } +} + +export class RecipientTokenAccountMintMismatchError extends RecipientClassificationError { + constructor() { + super( + `Cannot send to this address. This is a token account for a different token than the one you're trying to send. ${ + PERMANENT_LOSS_OF_FUNDS_MESSAGE + }`, + ); + this.name = 'RecipientTokenAccountMintMismatchError'; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/index.ts b/merged-packages/solana-wallet-snap/src/core/services/send/index.ts new file mode 100644 index 00000000..6ab4c8a5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/index.ts @@ -0,0 +1,5 @@ +export * from './RecipientClassifier'; +export * from './SendFeeCalculator'; +export * from './SendService'; +export * from './SendSolBuilder'; +export * from './SendSplTokenBuilder'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/send/types.ts b/merged-packages/solana-wallet-snap/src/core/services/send/types.ts new file mode 100644 index 00000000..c464b5a8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/send/types.ts @@ -0,0 +1,23 @@ +import type { Infer } from '@metamask/superstruct'; + +import type { + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + OnConfirmSendRequestStruct, + ValidationResponseStruct, +} from '../../handlers/onClientRequest/validation'; + +export type OnConfirmSendRequest = Infer; + +export type OnAddressInputRequest = Infer; + +export type OnAmountInputRequest = Infer; + +export type ValidationResponse = Infer; + +export enum SendErrorCodes { + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', + InsufficientBalance = 'InsufficientBalance', +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.test.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.test.ts new file mode 100644 index 00000000..3ed04dbe --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.test.ts @@ -0,0 +1,186 @@ +import { + getSignatureFromTransaction, + isTransactionMessageWithBlockhashLifetime, +} from '@solana/kit'; + +import { Network } from '../../constants/solana'; +import { fromBytesToCompilableTransactionMessage } from '../../sdk-extensions/codecs'; +import { + isTransactionMessageWithComputeUnitLimitInstruction, + isTransactionMessageWithComputeUnitPriceInstruction, + isTransactionMessageWithFeePayer, +} from '../../sdk-extensions/transaction-messages'; +import { deriveSolanaKeypairMock } from '../../test/mocks/utils/deriveSolanaKeypair'; +import logger from '../../utils/logger'; +import type { SolanaConnection } from '../connection'; +import { createMockConnection } from '../mocks/mockConnection'; +import { MOCK_SIGN_SCENARIO_JUPITERZ_WITH_DIFFERENT_FEE_PAYER } from './mocks/jupiterzWithDifferentFeePayer'; +import { MOCK_EXECUTION_SCENARIOS } from './mocks/scenarios'; +import { Signer } from './Signer'; + +jest.mock('@solana/kit', () => ({ + ...jest.requireActual('@solana/kit'), + getComputeUnitEstimateForTransactionMessageFactory: jest + .fn() + .mockReturnValue(jest.fn().mockResolvedValue(200000)), + sendTransactionWithoutConfirmingFactory: jest + .fn() + .mockReturnValue(jest.fn().mockResolvedValueOnce(undefined)), +})); + +jest.mock('../../utils/deriveSolanaKeypair', () => ({ + deriveSolanaKeypair: deriveSolanaKeypairMock, +})); + +describe('Signer', () => { + const mockScope = Network.Mainnet; + let mockConnection: SolanaConnection; + + let signer: Signer; + + beforeEach(async () => { + jest.clearAllMocks(); + mockConnection = createMockConnection(); + signer = new Signer(mockConnection, logger); + }); + + // Note the ".each" here + describe.each(MOCK_EXECUTION_SCENARIOS)('scenarios', (scenario) => { + const { + name, + scope, + fromAccount, + transactionMessageBase64Encoded, + getMultipleAccountsResponse, + signedTransaction, + signature, + } = scenario; + + beforeEach(async () => { + jest.clearAllMocks(); + + signer = new Signer(mockConnection, logger); + + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + ...mockConnection.getRpc(mockScope), + getMultipleAccounts: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockResolvedValue(getMultipleAccountsResponse?.result), + }), + }); + }); + + describe('partiallySignBase64String', () => { + describe('when the base64 string represents a transaction message', () => { + it(`Scenario ${name}: signs a transaction message successfully`, async () => { + const result = await signer.partiallySignBase64String( + transactionMessageBase64Encoded, + fromAccount, + scope, + ); + + const resultSignature = getSignatureFromTransaction(result); + + expect(result).toStrictEqual(signedTransaction); + expect(resultSignature).toBe(signature); + }); + + it(`Scenario ${name}: adds if missing a fee payer, a lifetimeConstraint, a compute unit limit and a compute unit price`, async () => { + const { messageBytes } = await signer.partiallySignBase64String( + transactionMessageBase64Encoded, + fromAccount, + scope, + ); + const transactionMessageAfterSigning = + await fromBytesToCompilableTransactionMessage( + messageBytes, + mockConnection.getRpc(scope), + ); + + expect( + isTransactionMessageWithFeePayer(transactionMessageAfterSigning), + ).toBe(true); + expect( + isTransactionMessageWithBlockhashLifetime( + transactionMessageAfterSigning, + ), + ).toBe(true); + expect( + isTransactionMessageWithComputeUnitLimitInstruction( + transactionMessageAfterSigning, + ), + ).toBe(true); + expect( + isTransactionMessageWithComputeUnitPriceInstruction( + transactionMessageAfterSigning, + ), + ).toBe(true); + }); + }); + }); + }); + + describe('when the fee payer is different from the account', () => { + it('signs the transaction message successfully', async () => { + const { scope, transactionRequestBase64Encoded, userAccount } = + MOCK_SIGN_SCENARIO_JUPITERZ_WITH_DIFFERENT_FEE_PAYER; + + const partiallySignedTransaction = await signer.partiallySignBase64String( + transactionRequestBase64Encoded, + userAccount, + scope, + ); + + expect(partiallySignedTransaction).toStrictEqual({ + lifetimeConstraint: { + blockhash: '8o7LFQ8aJ1eZkB1ShjnwzwnfkptjbRoD8gXPkib7K1DR', + lastValidBlockHeight: 18446744073709551615n, + }, + messageBytes: new Uint8Array([ + 128, 2, 0, 6, 12, 166, 175, 172, 132, 2, 105, 14, 33, 122, 105, 220, + 7, 50, 189, 98, 255, 64, 29, 138, 160, 54, 67, 17, 144, 23, 120, 198, + 47, 124, 254, 220, 189, 153, 176, 2, 143, 157, 158, 175, 50, 134, 226, + 145, 237, 10, 87, 130, 63, 185, 200, 241, 76, 205, 92, 21, 136, 75, + 157, 88, 79, 89, 248, 223, 116, 15, 102, 248, 21, 53, 113, 144, 71, + 137, 127, 26, 38, 205, 4, 248, 209, 38, 217, 101, 67, 105, 42, 24, 55, + 43, 166, 48, 148, 104, 82, 164, 117, 48, 36, 129, 80, 117, 87, 70, 77, + 249, 251, 108, 166, 221, 92, 72, 25, 62, 193, 83, 16, 86, 187, 56, 36, + 212, 5, 67, 0, 96, 91, 209, 153, 132, 236, 176, 69, 168, 106, 118, + 244, 164, 143, 111, 220, 64, 42, 101, 164, 63, 70, 9, 95, 49, 228, + 192, 29, 10, 64, 248, 0, 215, 209, 158, 155, 14, 37, 7, 136, 203, 142, + 76, 71, 239, 255, 180, 111, 163, 36, 124, 167, 121, 83, 255, 163, 42, + 243, 187, 206, 39, 92, 25, 109, 6, 96, 240, 240, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 74, 88, 73, 251, 114, 163, 187, 233, 31, 220, 91, 14, 106, 87, + 246, 60, 90, 28, 180, 91, 32, 103, 166, 237, 12, 172, 211, 99, 149, + 200, 161, 2, 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, + 195, 155, 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, + 64, 0, 0, 0, 198, 250, 122, 243, 190, 219, 173, 58, 61, 101, 243, 106, + 171, 201, 116, 49, 177, 187, 228, 194, 210, 246, 224, 228, 124, 166, + 2, 3, 69, 47, 93, 97, 6, 155, 136, 87, 254, 171, 129, 132, 251, 104, + 127, 99, 70, 24, 192, 53, 218, 196, 57, 220, 26, 235, 59, 85, 152, + 160, 240, 0, 0, 0, 0, 1, 6, 221, 246, 225, 215, 101, 161, 147, 217, + 203, 225, 70, 206, 235, 121, 172, 28, 180, 133, 237, 95, 91, 55, 145, + 58, 140, 245, 133, 126, 255, 0, 169, 115, 209, 175, 103, 12, 170, 169, + 61, 216, 128, 84, 178, 158, 117, 225, 255, 22, 37, 225, 41, 247, 26, + 101, 28, 81, 187, 145, 38, 13, 50, 65, 0, 3, 8, 0, 9, 3, 57, 41, 0, 0, + 0, 0, 0, 0, 8, 0, 5, 2, 126, 144, 0, 0, 7, 12, 1, 0, 4, 2, 7, 5, 9, + 11, 10, 11, 6, 3, 35, 168, 96, 183, 163, 92, 10, 40, 160, 64, 66, 15, + 0, 0, 0, 0, 0, 151, 90, 69, 0, 0, 0, 0, 0, 20, 154, 210, 104, 0, 0, 0, + 0, 2, 0, 0, 0, + ]), + signatures: { + CDg3bPoM21fSXEzrXWHWyJR33JHX6xaYboq5p7s4uo48: null, + BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP: new Uint8Array([ + 21, 3, 51, 147, 54, 231, 170, 228, 92, 92, 247, 188, 98, 138, 98, + 136, 235, 184, 108, 14, 188, 100, 77, 157, 190, 84, 23, 137, 255, + 140, 87, 97, 211, 1, 26, 226, 180, 23, 184, 123, 0, 87, 202, 186, + 32, 95, 120, 125, 33, 74, 148, 160, 87, 80, 246, 228, 105, 28, 129, + 56, 232, 165, 79, 0, + ]), + }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.ts new file mode 100644 index 00000000..937ae15a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/Signer.ts @@ -0,0 +1,239 @@ +import type { Infer } from '@metamask/superstruct'; +import type { + BaseTransactionMessage, + Transaction, + TransactionWithLifetime, +} from '@solana/kit'; +import { + addSignersToTransactionMessage, + createKeyPairFromPrivateKeyBytes, + createKeyPairSignerFromPrivateKeyBytes, + isTransactionMessageWithBlockhashLifetime, + partiallySignTransaction, + partiallySignTransactionMessageWithSigners, + pipe, +} from '@solana/kit'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { Network } from '../../constants/solana'; +import type { DecompileTransactionMessageFetchingLookupTablesConfig } from '../../sdk-extensions/codecs'; +import { + fromBytesToCompilableTransactionMessage, + fromUnknowBase64StringToTransactionOrTransactionMessage, +} from '../../sdk-extensions/codecs'; +import { + estimateAndOverrideComputeUnitLimit, + isTransactionMessageWithComputeUnitLimitInstruction, + isTransactionMessageWithComputeUnitPriceInstruction, + setComputeUnitPriceInstructionIfMissing, + setTransactionMessageFeePayerIfMissing, + setTransactionMessageLifetimeUsingBlockhashIfMissing, +} from '../../sdk-extensions/transaction-messages'; +import { deriveSolanaKeypair } from '../../utils/deriveSolanaKeypair'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { Base64Struct } from '../../validation/structs'; +import type { SolanaConnection } from '../connection'; + +/** + * Signer class for signing transactions and transaction messages. + */ +export class Signer { + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + static readonly defaultComputeUnitPriceInMicroLamportsPerComputeUnit = 10000n; + + constructor(connection: SolanaConnection, logger: ILogger) { + this.#connection = connection; + this.#logger = createPrefixedLogger(logger, '[🖋️ Signer]'); + } + + /** + * Partially signs an arbitrary base64 string, adapting the logic depending on whether + * the string represents a transaction or a transaction message. + * + * - If it's a transaction message, we need to need add all missing fields, then sign it. + * - If it's a transaction, we need to need add the account's signature. + * + * @param base64String - The base64 encoded transaction or transaction message to sign. + * @param account - The account to sign the transaction or transaction message with. + * @param network - The network on which the transaction is being sent. + * @param config - The configuration for the request. + * @returns The signed transaction. + * @throws If the base64 string is not a valid transaction or transaction message. + */ + async partiallySignBase64String( + base64String: Infer, + account: SolanaKeyringAccount, + network: Network, + config?: DecompileTransactionMessageFetchingLookupTablesConfig, + ): Promise { + this.#logger.log('Partially sign base64 string', { + base64String, + account, + network, + config, + }); + + const rpc = this.#connection.getRpc(network); + + // The received base64 string can either represent a transaction or a transaction message. + const transactionMessageOrTransaction = + await fromUnknowBase64StringToTransactionOrTransactionMessage( + base64String, + rpc, + config, + ); + + // It's a transaction message, add all missing fields, then partially sign it. + if ('instructions' in transactionMessageOrTransaction) { + return this.#prepareAndPartiallySignTransactionMessage( + transactionMessageOrTransaction, + account, + network, + ); + } + + const isUnsigned = Object.values( + transactionMessageOrTransaction.signatures, + ).every((signature) => !signature); + + // It's an unsigned transaction, grab the message from the transaction and apply the same logic as above. + if (isUnsigned) { + const { messageBytes } = transactionMessageOrTransaction; + + const transactionMessageFromUnsignedTransaction = + await fromBytesToCompilableTransactionMessage( + messageBytes, + rpc, + config, + ); + + return this.#prepareAndPartiallySignTransactionMessage( + transactionMessageFromUnsignedTransaction, + account, + network, + ); + } + + // It's a partially signed transaction, so we cannot alter its content, and we add the account's signature. + return this.#partiallySignTransaction( + transactionMessageOrTransaction, + account, + ); + } + + /** + * Prepares the passed transaction message by: + * - adding a feePayer if missing, + * - adding a lifetimeConstraint if missing, + * - adding a computeUnitLimit if missing, + * - adding a computeUnitPrice if missing, + * - partially signing it with the passed account. + * + * @param transactionMessage - The transaction message to sign. + * @param account - The account to sign the transaction message with. + * @param scope - The network where the transaction is to be sent. + * @returns The partially signed transaction. + */ + async #prepareAndPartiallySignTransactionMessage( + transactionMessage: BaseTransactionMessage, + account: SolanaKeyringAccount, + scope: Network, + ): Promise> { + // First, make sure the transaction message has a fee payer, lifetime constraint and compute unit price + const hasLifetimeConstraint = + isTransactionMessageWithBlockhashLifetime(transactionMessage); + + const blockhash = hasLifetimeConstraint + ? transactionMessage.lifetimeConstraint // Use any value, it won't be used + : await this.#connection.getLatestBlockhash(scope); + + const hasComputeUnitPrice = + isTransactionMessageWithComputeUnitPriceInstruction(transactionMessage); + + const microLamports = + Signer.defaultComputeUnitPriceInMicroLamportsPerComputeUnit; + + const hasComputeUnitLimit = + isTransactionMessageWithComputeUnitLimitInstruction(transactionMessage); + + /** + * We add a compute unit limit if it's missing, but also if we the compute unit price is missing. + * Why? Because we will add an extra instruction for the compute unit price, which incidentally increases + * the compute unit consumed by the transaction. So we need to re-estimate the compute unit limit and override it. + */ + const shouldSetComputeUnitLimit = + !hasComputeUnitLimit || !hasComputeUnitPrice; + + const { privateKeyBytes } = await deriveSolanaKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + const signer = + await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); + + const compilableTransactionMessage = await pipe( + transactionMessage, + (tx) => setTransactionMessageFeePayerIfMissing(signer.address, tx), + (tx) => + setTransactionMessageLifetimeUsingBlockhashIfMissing(blockhash, tx), + (tx) => + setComputeUnitPriceInstructionIfMissing(tx, { + microLamports, + }), + // If the transaction message had no compute unit price, we just added one, so we need to recompute the compute unit limit + async (tx) => + shouldSetComputeUnitLimit + ? estimateAndOverrideComputeUnitLimit( + tx, + this.#connection.getRpc(scope), + ) + : tx, + ); + + // Attach the signers to the transaction message + const transactionMessageWithSigners = addSignersToTransactionMessage( + [signer], + compilableTransactionMessage, + ); + + /** + * Partially sign the transaction message with the signers. + * + * When we "partially" sign, we only sign with the passed signers, and don't expect the + * transaction to be fully signed afterwards. + * + * It's important to do this because the transaction might also expect signatures from other signers, + * so we need to return it as is, so that more signers can sign later. + */ + const signedTransaction = await partiallySignTransactionMessageWithSigners( + transactionMessageWithSigners, + ); + + return signedTransaction; + } + + /** + * Partially signs a transaction with the passed account. + * + * @param transaction - The transaction to partially sign. + * @param account - The account to partially sign the transaction with. + * @returns The partially signed transaction. + */ + async #partiallySignTransaction( + transaction: Transaction, + account: SolanaKeyringAccount, + ) { + const { privateKeyBytes } = await deriveSolanaKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const keyPair = await createKeyPairFromPrivateKeyBytes(privateKeyBytes); + + return partiallySignTransaction([keyPair], transaction); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/index.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/index.ts new file mode 100644 index 00000000..55cc52dd --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/index.ts @@ -0,0 +1 @@ +export * from './Signer'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/jupiterzWithDifferentFeePayer.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/jupiterzWithDifferentFeePayer.ts new file mode 100644 index 00000000..07af71f5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/jupiterzWithDifferentFeePayer.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { address, blockhash } from '@solana/kit'; +import type { CompilableTransactionMessage } from '@solana/kit'; + +import { Network } from '../../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../../../test/mocks/solana-keyring-accounts'; + +const scope = Network.Devnet; + +const userAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + +const fromAccountPrivateKeyBytes = + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[userAccount.id]!; + +const transactionRequestBase64Encoded = + 'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIABgymr6yEAmkOIXpp3AcyvWL/QB2KoDZDEZAXeMYvfP7cvZmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N90DiUHiMuOTEfv/7RvoyR8p3lT/6Mq87vOJ1wZbQZg8PAPZvgVNXGQR4l/GibNBPjRJtllQ2kqGDcrpjCUaFKkdTAkgVB1V0ZN+ftspt1cSBk+wVMQVrs4JNQFQwBgW9GZhOywRahqdvSkj2/cQCplpD9GCV8x5MAdCkD4ANfRnpsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAEG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqUpYSftyo7vpH9xbDmpX9jxaHLRbIGem7Qys02OVyKECxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWFz0a9nDKqpPdiAVLKedeH/FiXhKfcaZRxRu5EmDTJBAAMHAAkDOSkAAAAAAAAHAAUCfpAAAAoMAQAFAwoCCwkICQYEI6hgt6NcCiigQEIPAAAAAACXWkUAAAAAABSa0mgAAAAAAgAAAA=='; + +const transactionMessage: CompilableTransactionMessage = { + instructions: [ + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: new Uint8Array([3, 57, 41, 0, 0, 0, 0, 0, 0]), + }, + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: new Uint8Array([2, 126, 144, 0, 0]), + }, + { + programAddress: address('61DFfeTKM7trxYcPQCM78bJ794ddZprZpAwAnLiwTpYH'), + accounts: [ + { address: address(userAccount.address), role: 3 }, + { + address: address('CDg3bPoM21fSXEzrXWHWyJR33JHX6xaYboq5p7s4uo48'), + role: 3, + }, + { + address: address('9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg'), + role: 1, + }, + { + address: address('238BYhc1qz4S62GBFGhFm3233PC9S6dmhtikjzKendsz'), + role: 1, + }, + { + address: address('61DFfeTKM7trxYcPQCM78bJ794ddZprZpAwAnLiwTpYH'), + role: 0, + }, + { + address: address('xDTVdH7CGuDhRebhtfSxE33ZL1crUKwM3GnSb9a7DKm'), + role: 1, + }, + { + address: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + role: 0, + }, + { + address: address('So11111111111111111111111111111111111111112'), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + role: 0, + }, + { address: address('11111111111111111111111111111111'), role: 0 }, + { + address: address('4EvracGAstedg3H7oTkERRshLvQ7hwKjSc9BuWgegFjN'), + role: 1, + }, + ], + data: new Uint8Array([ + 168, 96, 183, 163, 92, 10, 40, 160, 64, 66, 15, 0, 0, 0, 0, 0, 151, 90, + 69, 0, 0, 0, 0, 0, 20, 154, 210, 104, 0, 0, 0, 0, 2, 0, 0, + ]), + }, + ], + version: 0, + feePayer: { + address: address('CDg3bPoM21fSXEzrXWHWyJR33JHX6xaYboq5p7s4uo48'), // Note that this is NOT the user address + }, + lifetimeConstraint: { + blockhash: blockhash('8o7LFQ8aJ1eZkB1ShjnwzwnfkptjbRoD8gXPkib7K1DR'), + lastValidBlockHeight: 18446744073709551615n, + }, +}; + +export const MOCK_SIGN_SCENARIO_JUPITERZ_WITH_DIFFERENT_FEE_PAYER = { + scope, + userAccount, + fromAccountPrivateKeyBytes, + transactionRequestBase64Encoded, + transactionMessage, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/README.md b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/README.md new file mode 100644 index 00000000..ae1be90d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/README.md @@ -0,0 +1,9 @@ +A scenario file represents a sample transacting situation. Each scenario describes a sample "real world" transaction message, as well as relevant or derived data like: + +- The transaction message +- Its base64 encoded version +- The signer account +- The signed transaction +- The signature + +All of the above are expected to match together, and the unit tests in `Signer.test.ts` verify this fact. diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/index.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/index.ts new file mode 100644 index 00000000..83140072 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/index.ts @@ -0,0 +1,11 @@ +import { MOCK_EXECUTION_SCENARIO_LIFI_SWAP } from './lifiSwap'; +import { MOCK_EXECUTION_SCENARIO_NO_OP_WITH_HELLO_WORLD_DATA } from './noOpWithHelloWorldData'; +import { MOCK_EXECUTION_SCENARIO_SEND_SOL } from './sendSol'; +import { MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN } from './sendSplToken'; + +export const MOCK_EXECUTION_SCENARIOS = [ + MOCK_EXECUTION_SCENARIO_LIFI_SWAP, + MOCK_EXECUTION_SCENARIO_SEND_SOL, + MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN, + MOCK_EXECUTION_SCENARIO_NO_OP_WITH_HELLO_WORLD_DATA, +]; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/lifiSwap.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/lifiSwap.ts new file mode 100644 index 00000000..806ecb23 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/lifiSwap.ts @@ -0,0 +1,1401 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { address, blockhash } from '@solana/kit'; +import type { CompilableTransactionMessage } from '@solana/kit'; + +import { Network } from '../../../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../../../../test/mocks/solana-keyring-accounts'; +import type { MockExecutionScenario } from './types'; + +const scope = Network.Mainnet; + +// Same from and to account +const account = MOCK_SOLANA_KEYRING_ACCOUNT_1; + +const fromAccountPrivateKeyBytes = + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[account.id]!; + +const transactionMessage: CompilableTransactionMessage = { + version: 0, + feePayer: { + address: address('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + }, + lifetimeConstraint: { + blockhash: blockhash('F3mgS4DGisBt8Uc2MfM5r4ow2RcPFJrp333h7YSgTin6'), + lastValidBlockHeight: 18446744073709551615n, + }, + instructions: [ + { + programAddress: address('3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br'), + accounts: [ + { + address: address('SysvarC1ock11111111111111111111111111111111'), + role: 0, + }, + { + address: address('EYekEEy28BaAKDspYxEcTRjgJkrgTv8GdbZ7GsdaYYye'), + role: 0, + }, + ], + data: Uint8Array.from([0, 139, 203, 23, 160, 77, 48, 130, 0]), + }, + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 184, 129, 22, 0]), + }, + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([3, 32, 161, 7, 0, 0, 0, 0]), + }, + { + programAddress: address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + accounts: [ + { + address: address('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + role: 3, + }, + { + address: address('9gY6g14A8N2Jvdy4dwoZmXvPNhPPAn58GQxYhu6cSRAy'), + role: 1, + }, + { + address: address('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + role: 3, + }, + { + address: address('DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'), + role: 0, + }, + { + address: address('11111111111111111111111111111111'), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + ], + data: Uint8Array.from([1]), + }, + { + programAddress: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + accounts: [ + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB'), + role: 0, + }, + { + address: address('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + role: 3, + }, + { + address: address('5kBNWdETUczauBADqGQNUcAAhrCntL2TMUtpfysmyy5p'), + role: 1, + }, + { + address: address('59v2cSbCsnyaWymLnsq6TWzE6cEN5KJYNTBNrcP4smRH'), + role: 1, + }, + { + address: address('4hwwqG3yPawkEzFyGMvHqM8rnjDPEhZRG8gggmAviKUb'), + role: 1, + }, + { + address: address('9gY6g14A8N2Jvdy4dwoZmXvPNhPPAn58GQxYhu6cSRAy'), + role: 1, + }, + { + address: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + addressIndex: 11, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'), + role: 0, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + { + address: address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + role: 0, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('DbTk2SNKWxu9TJbPzmK9HcQCAmraBCFb5VMo8Svwh34z'), + addressIndex: 4, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 1, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('2LNvcmeTwEtWhtbFiNWcQ5SAVa23np1syTrgjYeayE83'), + addressIndex: 9, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 1, + }, + { + address: address('9n5JZGhE31tWh2guMgke5Xhx178oMUexCjkA56E9RL15'), + addressIndex: 2, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 1, + }, + { + address: address('59v2cSbCsnyaWymLnsq6TWzE6cEN5KJYNTBNrcP4smRH'), + role: 1, + }, + { + address: address('DPNFSk4kUZ7p2k2MdrQLrhDzdULCRSSj44M8e3c5n9oH'), + role: 1, + }, + { + address: address('27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4'), + addressIndex: 10, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + addressIndex: 11, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('AauSbyE34GsfndXiYuURRcFFMZFLxuwqCKwriwicvonP'), + addressIndex: 15, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 1, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB'), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6'), + addressIndex: 12, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('FWdGExzsHnoKeo6KNR9aYnvARwNaSUjMgwNusE3zDTYL'), + role: 1, + }, + { + address: address('3GbT545yUsVpzKMc2ZnnK2pSMcB8A4YtPTamMj5qtSjc'), + role: 1, + }, + { + address: address('2kN79B8PGMo6nP7fi31XT9iEj9dvZqp5eKZpX731Wke6'), + role: 1, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('HQtgECMQPQj73jQCuCapqRMMQAHAJwB1MKCvS4iro4i2'), + addressIndex: 90, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('92znhuo5mSBriZaiSfGNdiFoivLTxhgnZKUy8FMr9f6i'), + addressIndex: 19, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('Bg9mdAYRpwkCYriVfhPdtzwB544f4HKvX2z6of6qccQP'), + addressIndex: 18, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('DPNFSk4kUZ7p2k2MdrQLrhDzdULCRSSj44M8e3c5n9oH'), + role: 1, + }, + { + address: address('7x4VcEX8aLd3kFsNWULTp1qFgVtDwyWSxpTGQkoMM6XX'), + role: 1, + }, + { + address: address('27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4'), + addressIndex: 10, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('So11111111111111111111111111111111111111112'), + addressIndex: 16, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('CDwLouXTtamtG4qgnmM8v9rXhgGaeBoHmBSZv5FVwPTV'), + addressIndex: 16, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB'), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6'), + addressIndex: 12, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + addressIndex: 6, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('Dw943QuxxvB2Se5etoSY3EShb8va8U7zRXHF52DLQcNA'), + addressIndex: 17, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('FvZgh2aSjZHFsREiqiwp1SSkjEj4soem73LxqKzSredy'), + addressIndex: 20, + lookupTableAddress: address( + 'DFiF5MtKEDSzGh81zbip3P4zw3Y6JAubUbFdZqAogqyw', + ), + role: 1, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + { + address: address('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'), + addressIndex: 34, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 0, + }, + { + address: address('6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB'), + role: 0, + }, + { + address: address('3h2e43PunVA5K34vwKCLHWhZF4aZpyaC9RmxvshGAQpL'), + addressIndex: 33, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 0, + }, + { + address: address('ysq96dVZrrMVRassYB2Vr5cHFWoSQDRzQRGNmRRtr1L'), + addressIndex: 35, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 1, + }, + { + address: address('7x4VcEX8aLd3kFsNWULTp1qFgVtDwyWSxpTGQkoMM6XX'), + role: 1, + }, + { + address: address('4hwwqG3yPawkEzFyGMvHqM8rnjDPEhZRG8gggmAviKUb'), + role: 1, + }, + { + address: address('7B9icCwrEAwJhvpYtPixagtxM3AVXDwWrZkQiE7YiGpX'), + addressIndex: 36, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 1, + }, + { + address: address('B7b8YgV8rJXdeuMyfP5mhZL3SU46si9owrKT5a8Ss8Nr'), + addressIndex: 30, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 1, + }, + { + address: address('Gajn4cvFPSFNFCUrvLbTwjkgShvTTS4TGPxYqc1HcG6a'), + addressIndex: 29, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 1, + }, + { + address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + addressIndex: 7, + lookupTableAddress: address( + 'FucMYjkfiJo4G9fbhQuc4cwpXnyhQRLRNwJzBmxRHryr', + ), + role: 0, + }, + { + address: address('ASoXooAr9FdVvNwRmRJtMcBqVszJh6eD8JQ68kZXoUom'), + role: 1, + }, + { + address: address('8N6UVVK4hk42EwxCosZ96w8GMSDiSTC5AdNxbc7oFH5g'), + addressIndex: 32, + lookupTableAddress: address( + '6HSd2giA4GPy4Mr2SwGN2yURKTWLB8Wveu5E8L73QBp1', + ), + role: 1, + }, + { + address: address('38xsdNK9eQpZ82wHfRoAcpgvfPM7dSdciPHmjZcN5vxy'), + role: 1, + }, + { + address: address('F45bCYn3drfWyPLGqcgjdc3LqWBBfqXHExFXgQv7MK6V'), + role: 1, + }, + { + address: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + role: 0, + }, + ], + data: Uint8Array.from([ + 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, 0, 1, 38, + 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, 72, 81, + 1, 0, 0, 0, 50, 0, 0, + ]), + }, + ], +}; + +/** + * Lifi tx message (base64 encoded), as received in the `data` field from the response of this call: + * + * curl --request GET \ + * --url 'https://li.quest/v1/quote?fromToken=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&toToken=DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263&fromAddress=FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo&toAddress=FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo&fromAmount=1000000&fromChain=SOL&toChain=SOL' \ + * --header 'accept: application/json' + */ +const transactionMessageBase64Encoded = + 'gAEAChfds67pR0IYlL1XKFGjzC+zBZIA7vT1dj7nUBTUwAs7rBn3hrWmXImk+UetGwvWumZpcEhF+xT5THD5os2Svp67H8GdfJ4jkOslaYOff/X0SeF33Cha5Ij9DKlo3QaosfIhtgnDmdJAVdjfmyv5dEGsSWgYEYPaqW8v8hSJozhYIzcQa4p5MinFjNMq4vDm+n249hE5Vmwe+Adkq87GTDegPbdaVhuqlrT5hZnIkdcW67eCFvm9ZzXM9jeWm2GwnLBGfnLp6qHLD5IgtqLXr5clzwoa2ns4KdysosGA2yFHt2dBBA/kB6qwUEagfnGzH2GY12XE3va/gn3W4Loqy/D+gP5PMjWwL4nGY8i3EGxqLHt/i3x/EskcO1ftQjYqQtqMVVDE+U/Vngb6x6+HbBOGrDQOx73j0k813TrK9dmptLgHDBoOIz6tGmWT+r9wa3YtqouLIv01/IKynGlc4TnE0M2MheikqA6gkuj1PhXVDgPc7eSLCseVMCy/WUgTmKbXmnZ/QcH3X8YTJ//GR4yvL7LCHdfHPhS8B+4hpoFusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FVRPixdukLDvYMw2r2DTumX5VA1ifoAVfXgkOTnLswDEoyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAC0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6LwHxW5grT0/F3OC6sZUj7of0yz9kMoCs+fPoYX9znOYyUOdJ8PoWqEFw7H8n7xwkhg1P1tPfo1/6arncTl4PJkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwan1RcYx3TJKFZjmGkdXraLXrijm0ttXHNVWyEAAAAA0LlMW0o1bBeKH7IjSDuI9G0a+7kgVaW9ubbU+rklS9sFDgIWFAkAi8sXoE0wggARAAUCuIEWABEACAMgoQcAAAAAEAYACAATDS0BARVDLQ8ABgUECCoTFRUSFSslKyIjBQooKiQrDy0tKSsMAwEVKyErHB0KBygsHisPLS0pKx8gFScPJhsHBBcZGi0JGAILFSzBIJszQdacgQMDAAAAJmQAASZkAQIaZAIDQEIPAAAAAAC78khRAQAAADIAAANOgLKSmCyUmuksqMclFoVdtmiFizz7/yF11zNd6VSAxgUkIB4dIwIhIrYRAfelfqMdEh4JHXx6VS3GXpyeWhNKQlBsx9m2I8c+BhMSEBEUWgDdfctSzc+t7n0tohMIoz7S6USQkKhKDRCUSx6C3SjhJQQJAg8EBgoMCwYQBw=='; + +const signedTransaction = { + lifetimeConstraint: { + blockhash: blockhash('F3mgS4DGisBt8Uc2MfM5r4ow2RcPFJrp333h7YSgTin6'), + lastValidBlockHeight: 18446744073709551615n, + }, + messageBytes: Uint8Array.from([ + 128, 1, 0, 10, 23, 221, 179, 174, 233, 71, 66, 24, 148, 189, 87, 40, 81, + 163, 204, 47, 179, 5, 146, 0, 238, 244, 245, 118, 62, 231, 80, 20, 212, 192, + 11, 59, 172, 25, 247, 134, 181, 166, 92, 137, 164, 249, 71, 173, 27, 11, + 214, 186, 102, 105, 112, 72, 69, 251, 20, 249, 76, 112, 249, 162, 205, 146, + 190, 158, 187, 31, 193, 157, 124, 158, 35, 144, 235, 37, 105, 131, 159, 127, + 245, 244, 73, 225, 119, 220, 40, 90, 228, 136, 253, 12, 169, 104, 221, 6, + 168, 177, 242, 33, 182, 9, 195, 153, 210, 64, 85, 216, 223, 155, 43, 249, + 116, 65, 172, 73, 104, 24, 17, 131, 218, 169, 111, 47, 242, 20, 137, 163, + 56, 88, 35, 55, 16, 107, 138, 121, 50, 41, 197, 140, 211, 42, 226, 240, 230, + 250, 125, 184, 246, 17, 57, 86, 108, 30, 248, 7, 100, 171, 206, 198, 76, 55, + 160, 61, 183, 90, 86, 27, 170, 150, 180, 249, 133, 153, 200, 145, 215, 22, + 235, 183, 130, 22, 249, 189, 103, 53, 204, 246, 55, 150, 155, 97, 176, 156, + 176, 70, 126, 114, 233, 234, 161, 203, 15, 146, 32, 182, 162, 215, 175, 151, + 37, 207, 10, 26, 218, 123, 56, 41, 220, 172, 162, 193, 128, 219, 33, 71, + 183, 103, 65, 4, 15, 228, 7, 170, 176, 80, 70, 160, 126, 113, 179, 31, 97, + 152, 215, 101, 196, 222, 246, 191, 130, 125, 214, 224, 186, 42, 203, 240, + 254, 128, 254, 79, 50, 53, 176, 47, 137, 198, 99, 200, 183, 16, 108, 106, + 44, 123, 127, 139, 124, 127, 18, 201, 28, 59, 87, 237, 66, 54, 42, 66, 218, + 140, 85, 80, 196, 249, 79, 213, 158, 6, 250, 199, 175, 135, 108, 19, 134, + 172, 52, 14, 199, 189, 227, 210, 79, 53, 221, 58, 202, 245, 217, 169, 180, + 184, 7, 12, 26, 14, 35, 62, 173, 26, 101, 147, 250, 191, 112, 107, 118, 45, + 170, 139, 139, 34, 253, 53, 252, 130, 178, 156, 105, 92, 225, 57, 196, 208, + 205, 140, 133, 232, 164, 168, 14, 160, 146, 232, 245, 62, 21, 213, 14, 3, + 220, 237, 228, 139, 10, 199, 149, 48, 44, 191, 89, 72, 19, 152, 166, 215, + 154, 118, 127, 65, 193, 247, 95, 198, 19, 39, 255, 198, 71, 140, 175, 47, + 178, 194, 29, 215, 199, 62, 20, 188, 7, 238, 33, 166, 129, 110, 177, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 40, 61, 13, 210, 130, 53, 79, 239, 10, 227, 176, 226, 39, + 211, 124, 216, 156, 162, 102, 251, 23, 221, 248, 247, 203, 124, 206, 251, + 228, 235, 220, 85, 81, 62, 44, 93, 186, 66, 195, 189, 131, 48, 218, 189, + 131, 78, 233, 151, 229, 80, 53, 137, 250, 0, 85, 245, 224, 144, 228, 231, + 46, 204, 3, 18, 140, 151, 37, 143, 78, 36, 137, 241, 187, 61, 16, 41, 20, + 142, 13, 131, 11, 90, 19, 153, 218, 255, 16, 132, 4, 142, 123, 216, 219, + 233, 248, 89, 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, + 155, 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, + 0, 180, 63, 250, 39, 245, 215, 246, 74, 116, 192, 155, 31, 41, 88, 121, 222, + 75, 9, 171, 54, 223, 201, 221, 81, 75, 50, 26, 167, 179, 140, 229, 232, 188, + 7, 197, 110, 96, 173, 61, 63, 23, 115, 130, 234, 198, 84, 143, 186, 31, 211, + 44, 253, 144, 202, 2, 179, 231, 207, 161, 133, 253, 206, 115, 152, 201, 67, + 157, 39, 195, 232, 90, 161, 5, 195, 177, 252, 159, 188, 112, 146, 24, 53, + 63, 91, 79, 126, 141, 127, 233, 170, 231, 113, 57, 120, 60, 153, 4, 121, + 213, 91, 242, 49, 192, 110, 238, 116, 197, 110, 206, 104, 21, 7, 253, 177, + 178, 222, 163, 244, 142, 81, 2, 177, 205, 162, 86, 188, 19, 143, 6, 167, + 213, 23, 24, 199, 116, 201, 40, 86, 99, 152, 105, 29, 94, 182, 139, 94, 184, + 163, 155, 75, 109, 92, 115, 85, 91, 33, 0, 0, 0, 0, 208, 185, 76, 91, 74, + 53, 108, 23, 138, 31, 178, 35, 72, 59, 136, 244, 109, 26, 251, 185, 32, 85, + 165, 189, 185, 182, 212, 250, 185, 37, 75, 219, 5, 14, 2, 22, 20, 9, 0, 139, + 203, 23, 160, 77, 48, 130, 0, 17, 0, 5, 2, 184, 129, 22, 0, 17, 0, 8, 3, 32, + 161, 7, 0, 0, 0, 0, 16, 6, 0, 8, 0, 19, 13, 45, 1, 1, 21, 67, 45, 15, 0, 6, + 5, 4, 8, 42, 19, 21, 21, 18, 21, 43, 37, 43, 34, 35, 5, 10, 40, 42, 36, 43, + 15, 45, 45, 41, 43, 12, 3, 1, 21, 43, 33, 43, 28, 29, 10, 7, 40, 44, 30, 43, + 15, 45, 45, 41, 43, 31, 32, 21, 39, 15, 38, 27, 7, 4, 23, 25, 26, 45, 9, 24, + 2, 11, 21, 44, 193, 32, 155, 51, 65, 214, 156, 129, 3, 3, 0, 0, 0, 38, 100, + 0, 1, 38, 100, 1, 2, 26, 100, 2, 3, 64, 66, 15, 0, 0, 0, 0, 0, 187, 242, 72, + 81, 1, 0, 0, 0, 50, 0, 0, 3, 78, 128, 178, 146, 152, 44, 148, 154, 233, 44, + 168, 199, 37, 22, 133, 93, 182, 104, 133, 139, 60, 251, 255, 33, 117, 215, + 51, 93, 233, 84, 128, 198, 5, 36, 32, 30, 29, 35, 2, 33, 34, 182, 17, 1, + 247, 165, 126, 163, 29, 18, 30, 9, 29, 124, 122, 85, 45, 198, 94, 156, 158, + 90, 19, 74, 66, 80, 108, 199, 217, 182, 35, 199, 62, 6, 19, 18, 16, 17, 20, + 90, 0, 221, 125, 203, 82, 205, 207, 173, 238, 125, 45, 162, 19, 8, 163, 62, + 210, 233, 68, 144, 144, 168, 74, 13, 16, 148, 75, 30, 130, 221, 40, 225, 37, + 4, 9, 2, 15, 4, 6, 10, 12, 11, 6, 16, 7, + ]), + signatures: { + FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo: Uint8Array.from([ + 11, 34, 157, 107, 188, 168, 51, 10, 75, 131, 104, 67, 181, 199, 19, 239, + 51, 223, 112, 156, 236, 164, 212, 5, 131, 172, 248, 115, 35, 244, 132, + 219, 249, 196, 254, 153, 3, 104, 152, 72, 2, 208, 23, 254, 129, 247, 107, + 253, 49, 78, 204, 25, 25, 158, 145, 243, 244, 146, 176, 229, 187, 120, 7, + 0, + ]), + }, +}; + +const signedTransactionBase64Encoded = + 'AQsinWu8qDMKS4NoQ7XHE+8z33Cc7KTUBYOs+HMj9ITb+cT+mQNomEgC0Bf+gfdr/TFOzBkZnpHz9JKw5bt4BwCAAQAKF92zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusGfeGtaZciaT5R60bC9a6ZmlwSEX7FPlMcPmizZK+nrsfwZ18niOQ6yVpg59/9fRJ4XfcKFrkiP0MqWjdBqix8iG2CcOZ0kBV2N+bK/l0QaxJaBgRg9qpby/yFImjOFgjNxBrinkyKcWM0yri8Ob6fbj2ETlWbB74B2SrzsZMN6A9t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsEZ+cunqocsPkiC2otevlyXPChraezgp3KyiwYDbIUe3Z0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P6A/k8yNbAvicZjyLcQbGose3+LfH8SyRw7V+1CNipC2oxVUMT5T9WeBvrHr4dsE4asNA7HvePSTzXdOsr12am0uAcMGg4jPq0aZZP6v3Brdi2qi4si/TX8grKcaVzhOcTQzYyF6KSoDqCS6PU+FdUOA9zt5IsKx5UwLL9ZSBOYpteadn9BwfdfxhMn/8ZHjK8vssId18c+FLwH7iGmgW6xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoPQ3SgjVP7wrjsOIn03zYnKJm+xfd+PfLfM775OvcVVE+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMSjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAALQ/+if11/ZKdMCbHylYed5LCas238ndUUsyGqezjOXovAfFbmCtPT8Xc4LqxlSPuh/TLP2QygKz58+hhf3Oc5jJQ50nw+haoQXDsfyfvHCSGDU/W09+jX/pqudxOXg8mQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPBqfVFxjHdMkoVmOYaR1etoteuKObS21cc1VbIQAAAADQuUxbSjVsF4ofsiNIO4j0bRr7uSBVpb25ttT6uSVL2wUOAhYUCQCLyxegTTCCABEABQK4gRYAEQAIAyChBwAAAAAQBgAIABMNLQEBFUMtDwAGBQQIKhMVFRIVKyUrIiMFCigqJCsPLS0pKwwDARUrISscHQoHKCweKw8tLSkrHyAVJw8mGwcEFxkaLQkYAgsVLMEgmzNB1pyBAwMAAAAmZAABJmQBAhpkAgNAQg8AAAAAALvySFEBAAAAMgAAA06AspKYLJSa6SyoxyUWhV22aIWLPPv/IXXXM13pVIDGBSQgHh0jAiEithEB96V+ox0SHgkdfHpVLcZenJ5aE0pCUGzH2bYjxz4GExIQERRaAN19y1LNz63ufS2iEwijPtLpRJCQqEoNEJRLHoLdKOElBAkCDwQGCgwLBhAH'; + +const signature = + 'DuvWiKG54eKcB1s777TgaZWtF9R23sVuEaugRufdXyqhCf6owy5hYqxeL8sa6zoCPVQu3NxxhjWuz91CKgLE6SB'; + +const getMultipleAccountsResponse = { + jsonrpc: '2.0', + id: '26', + result: { + context: { + apiVersion: '2.1.11', + slot: 321203741, + }, + value: [ + { + data: { + parsed: { + info: { + addresses: [ + 'B2WiwMXJMCZ4sBEVW2Y7TLAGdLpkbURGg1PQLACx8oWf', + 'F7Dqd8iByoCkYN3vQdGY1oz3fobhKBFe5BTLvzb5ibQJ', + 'ErQBozq2E4Y82GHrFFU3LZo9pdbvmkgo5VKB477iXCrc', + 'So11111111111111111111111111111111111111112', + 'GUeYUGtaLXZABs2NfthByRcnn4AuQrfaDfojjic55cRd', + '8WoQhYkFoDQj55i3qWBYFbopdGX5tGNhEpgoazi3WocB', + '8Rjuu4DcJMBkqCMyknr92eutxhoJESH5SMXFxERkhVUv', + 'GNVNGspAK3w8Zw9FkgRnZve86j1jRTcjdEs6QJyKkihq', + '6gd4Lc7XpPZ9h8YNJRq25L39bv5G1NV9i57WC7HxA5UB', + '2VHjMPCqW3u6kmtVWQYwg9bjdk4tk377EshCxLQWgo8Q', + 'FdEWknJxJXnFAfAhEN24sj2WQVhxH7HGDZQJY3KRCdZm', + 'FB5DAm2bKFMxURni4f6H3M9LF7fNXAMKj9C5JWVXqqAG', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'So11111111111111111111111111111111111111112', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '7Sp7KdGYgNNwrfffxyftEbce4ZD9kPyqBwK8YkSXT5Bf', + 'BxRhumBeDN5tFUh3aB1P7bo1UYep5e3bQ9HPnaC2aeU5', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'DGJoF64y4D1w2NBXHWdcQt7gxFQg31Xd14WvJSvDpAe4', + 'FxS8kpWFAj9e5cTSKBfxizQm1WPkj19SUXdVbgwyZMRg', + '7c8CeJ7ZdoU2dzU8HgWBacW9Q7L4rw8UZ9JSTctLhn5r', + 'EzGdRrRdAhE8sxqKwggfXtinqhGeWkHNUynaPAqZv2Ai', + 'F8g9uGcK4UZ7FkACrZVAtgVH1EeVngqhoC6iRFaLMB9m', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '3VQgUoENNm7BjegwCr1f8HLhrXMTWPF2ucYumdFhcYT3', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'EhWVQi9VmGHZHZXaYWdkkCovwZ4gFti72yjUzXCBCDGu', + 'Gajn4cvFPSFNFCUrvLbTwjkgShvTTS4TGPxYqc1HcG6a', + 'B7b8YgV8rJXdeuMyfP5mhZL3SU46si9owrKT5a8Ss8Nr', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '8N6UVVK4hk42EwxCosZ96w8GMSDiSTC5AdNxbc7oFH5g', + '3h2e43PunVA5K34vwKCLHWhZF4aZpyaC9RmxvshGAQpL', + 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK', + 'ysq96dVZrrMVRassYB2Vr5cHFWoSQDRzQRGNmRRtr1L', + '7B9icCwrEAwJhvpYtPixagtxM3AVXDwWrZkQiE7YiGpX', + 'Hy6zmPES2NwEZtWseysChHgaeCVBUDbQeJbpZuCpewCi', + 'EnGWR4tHSGtejHot1D6JfRs8druTLfH6JnZwYFLie4bg', + 'J27wDetQ8Rg5cZoKE3BQav9TtKeNQhnkDR18S7Bv42pc', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'J4mKfRykN8uKdg8BzSPXZk5NCqvzLbfmBRzTgkjYmjhi', + '3rTCtwsBhnQwj8Drj7pkrDDm7s9QatNm2Ema1auidyzj', + 'Ac54Jwmghb4GTQETWUgcKcrhfryUJVWJGakf7re9ny2W', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'So11111111111111111111111111111111111111112', + '76KvfryGyxuRJcNcvt8YxY15BKHSy139NBMoxYR8Mrxr', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'ozkZePBCesci4M3sZiUH63vFwRgHhV5fb8jFJzHXvMQ', + 'FCuRRR9ZhBumrzV2GdSkbwB2mKXh9G5wwVgd5kS2qJFV', + '7TDm6WR7pYbRdc9KZyjJ3GChmBMkhM24Jn3Ts1DMhn2j', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'FByNyTuQpTo88haPXBYy7TXGKvwPVTC1bmSRs3xYhGEL', + 'F9gTfUuYrqkNu7NjeiKhW7tofjV2jaDtmnAZWAakivyW', + 'ARPwXj2LAHvB2yEAZ72LhJWbtnSy2SAtP1JgwSkR39XF', + 'Eqhrxky1YSYnUaqCGvoAr47VU9hZ5JPWCNWYXGmEuWUU', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '8eW3PFWrgDLjF977YGb9d6PChBDUj54yd9CYckD96bWL', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'DczfvwDMabwxTnMJD9rbDjQQ6auRDnpeX6wR9mbwXRFL', + 'AUjBXqgy6e35GZ3pAhrJ3ZsG5AHowTsVhZERPBHcjzKF', + '2upVYF6DVbUU761RF7eK7EDWCEFQDw4AqfSRZS8qfaty', + '92KQgzoMeGmwoybgLLRmfzdSoYVhvQmmVi8FosoJZKct', + 'Cyobc72qy1DCatsxxkdXVcLCbgXLFcqb87GGeGzW3Ugr', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'Dj67nArSFrC4LecDTrgm3ZARXoZvRdGS8Ub3pN7su29x', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '3h2ovau71sw3tYv4ZQVH1qivr76xogNZwnkm8UBxuZjb', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4CQUrzq6qaVtMVtWEL2CvaZjqBUxnMJtBgM6M3hHHDsJ', + 'stab1io8dHvK26KoHmTwwHyYmHRbUWbyEJx6CdrGabC', + 'swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ', + 'ArLSJrSstZ3kjeZDyMAgjfjad1qdRZHHYaCQTQeAcTpa', + '8yH6B6qWtt1Ms8HWiioSkJfboacjCJvzzHEyGzZ2pkmi', + 'vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV', + '95QUtvDkuoDZrNJiuh9MdahkpRNtSVhZRe83oepd8AM7', + '8BSWYgAczR36C7ukr32v7uTepoRhYJYxAVnpBtYniZTm', + 'AioJRQXvcDLRhHMd6DAkTbbMpgVx63qSGQYmRBS2vHYA', + '7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME', + 'HZeLxbZ9uHtSpwZC3LBr4Nubd14iHwz7bRSghRZf5VCG', + '467b68priPsCEDS4Z8DQod2WM7VE4eyyn9TeLn1obfbX', + '24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi', + '9zDkfxPAdTUTZi6DkWg2mwDCPuExZX2dzWqm7MTx8opQ', + 'So11111111111111111111111111111111111111112', + 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + 'DMsTbeCfX1crgAse5tver98KAMarPWeP3d6U3Gmmpump', + 'FvrsqQCAL4D3rVwiLFm7oM2TdjorNQdkCwpQazTabygK', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4DcdhiSfyVBYV6b41oUmk3c7DYWeymKzgMtmgStaXXRr', + 'D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6', + 's1bQGW8NAxb5HiSyRoPoaqkEy77J4MMkge8pnoKr5ku', + '25vvGHUqB4sjznpewZbNU2pjhSXScoSxEuyHozzZP9Ho', + '5y2xU1i1Nt14N9hKjKXR8YR2G8sf4AjjQ4vW2A1sEM5d', + '8e1hJjTv3kg4Cp2knSLYV48gnFjm3SiqzhRkj7NvSfei', + '8rKG4vu5BeTMjAhR2nyCoTGFnFrabtXggeES83UY4vZT', + '4ApZXh9QmGeWLZbL5qdxRKMbgyft97xtyGm7fxtZipyU', + 'HfmZiYfR3mokzG5w95dApKPLu3av2t3FhKaMDxUdxNHa', + '778puFmZupfRzy4qb9HuBV1DtVdcnEk67439uQ7BQ8fk', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'CW3tyZVY5SjNMLubr1Tot9SNWCrMoHHTphbSWCub6XUW', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '3F6Pv9eFYh8Pgq2BXxMvU5xL7ix7d6AZttpULiwmFKTo', + 'So11111111111111111111111111111111111111112', + '9GXimvmko74KMhoMRTnEXMQPkdqpjp7VjVVUWrRgdK6c', + 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + '6eEnNKKQrhzm2kCRDM1gWaUQnbfXmMHEvGyxBi9Wu14M', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + 'D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6', + '9yBr75m6TBZzQqHeh1ztfv454pmqAiAvHUHdpPQcpump', + 'Aq3WT1Rq6VJUuhtuvXcEogW3e4hczS6vLbtcbDYTf7j5', + '6J96agaBnFGhNpuKSLY6N6ze5Ywr1rqXpbduXNmzsXMp', + 'BSbbcvTknFnTiBn7fm8mG3LgCyvdRkDHKYDd5LsaazvW', + '22SxH315HKSK5nCFfxKjpttbrWER19BcYrHV2HmoEfBc', + 'ApidREJDvikauS5yxPSCwBCGWRBtHMLyFSMkn69v4Psq', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '7TUnitwdxtb93v42mNvd8vfmEAkQ4uMQkgyZzyNokMpq', + 'So11111111111111111111111111111111111111112', + '6k4FdYxdeibNT44w4wAiGgt37qW4ukSjB64gGyXvfcAJ', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '2KeFAaAn41qRGVsXbtYSCwwf69LVoe7EoNmUBnSZJo6K', + '33rzDK4eWYWtuS2AURPH3iqKS5gKWvek9xboCcXzyfuX', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'HjvAFy6CpiEB9z5VL4mrN1qwm3Zy1eJ12pSpcrzGmhTL', + 'CcWPeGuNbfPKCwAqUw7UZRfADAE7YpuzDLu8EtvPavMJ', + '2Qrhhfx5xd8mrj5mcT1Ap19tJPFXwWVj7nzwzxfFfs3Z', + 'EsHhSbC7u3ZLNbdXmUbcKBmN7QNJdzsx7iVBAkg6yT1P', + 'So11111111111111111111111111111111111111112', + 'DkjzcGDdmqRmdTEnprpRCTKtmCR3MjQWXmmC5Ar4J4DB', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'JAZLF4nfEF5PNLP68qnRTbJLZckDxvfkLc7Anu9wjsdu', + '6c51rvqRQhtY3fGs6ctwrt7tgzhRKfQ5XLArEyPAYYxW', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '67C2KbrNqHpFpuXmANQ9oiCUFqUdnNKUCXSVUFmfaJ3R', + 'G5UufBkwZ4c88f8p257SUd3bsZQd8EXzPjK216ipMMPi', + '49fhsojbPwJsp4dWotvscBfM9prmufFM8wFQwxmsgQGm', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'CuuP879JmPmEvyMJ2cBLT46qjFEyTWC6hnqhy8ogHsSG', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'DiYvACJ3j9eJsfv5nUiSvaYfb5DK3gY7x2Lsrb3Tf7L', + 'NJyN3aCw7s3xbefdP63w8rP6a92WLUcA2LFpw2Qge2j', + 'So11111111111111111111111111111111111111112', + 'MffvsvZLciJukqa9v1RRT89XurTGsUQLiNpF6Jb2Yz9', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4UaXZaLYcv57wYonWG65CnE7v9bdnQtggRpTRNV1mWi9', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'D6W2VmrNHQ8EnxpDoTaGBreXEoqBq1D5mQoSCFjK1eZZ', + '2Nt34H8HH6U2WjP9BD1uNinTSc7wj1zSVKQ4UT1ENdEm', + 'JCVpLqFEgB5W2qGFoKchC5w3zEbAbh3jLTo2PyD7FtEo', + 'So11111111111111111111111111111111111111112', + '4Szxwt9isAx147p94fTp5SDB4SxnNY95CwsLcsyNJ165', + '8WM1mpVSZMX6oaBhJtHNjYWSYfXVnNBUeeYsfpxVQzUV', + 'FNxpUJC5s6WFCjKnSUttGAtWCpNS7mKKSC7oxLp8xqL', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'J4XQiSTEt5oB6J4iJ9r5TWD7KJ9u1KAjRu2ujN9U1NtL', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GWZ6KD8PE1CWyyPu3y4qETz1TN5P1XruAcPv9zQo6JjG', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'DT91SxuFSorpNhXMvWwg3Uk63NwYcFXdTG2YC5tXFMTf', + 'So11111111111111111111111111111111111111112', + 'F5YpvF6Rfu59xag3PyNp8AC3xcfvuhJFxADhpixRx3F2', + 'ERZZAqAgifHko2LdPp2vZk9cwy8hFSLWiVJDbJfaGkWA', + 'ChkQDgs7ZPzzBBeFEaM3Vi4naKfjCKY36X9VuuxhzdjE', + '2XSwdLfaVK1ny7rH1H3TfkJHJL4ChFhhoBo3TmKSCvxV', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '8n7W4JgwTS2g7tVyMQWb4kWwaPnhLED8G1NLHrWXgo4N', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GPBEChvWcAtWWjnDDq6M8wNEKYqWBbHZhxhsx2osHqFq', + '8FZ6jypP2cjMB18EU8zbwNJxc5CFyVrbSMspZnoumuPD', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'So11111111111111111111111111111111111111112', + 'HWQq65BfTswm9gVU2jDTwZdnvzoBSm9ixd1gw1aWW8QS', + 'JDkEKmd9hpGPJbe2kqcWpQbR1xtGdq3SMzFyQ261tgCo', + '3Jy7mCKA4UDeyMcmD1YZgjTUYYisaMohnrYW5kYWk2sr', + '8hSPbPZBfezbt8ZAQMYiGXUbnokrMBNmNYrAmid6BYny', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '5b5D4RUkoyVGMyqNPuYJCK32GLMxX1N5LDQaV4BLmrts', + 'CCv5M9pi4yr3D9rAK9ZBULjBcVW4Gf1rkntAXBMefhw7', + 'So11111111111111111111111111111111111111112', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'AffVenptuAYJHPaz4QcdSJYmxG3zMSuY4zEnp8brjD6G', + 'EiHi5cwSXp7nWPq1LX3krQgsxdvFemmSwQsKJho8X8uX', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'DJoGspm5BHZE5LMKuXuhFLJP8nvUC23fzFC8bp7KpLw2', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '7MJ8f7G3VaPScM5KckWeFFoE8FoGGwYwLCT4XFqsyX2', + 'H5zS8tGBv4SphrHTYoctNJTaqUZgfve5ovnVQZeM7rru', + 'GEmDUpkhFzstTpssUkWDGJZexrFR7Zvz2Vc3pzTQjDf9', + 'So11111111111111111111111111111111111111112', + 'iMSrHe821cJPaM4FXdQ9wpcR8mDp5BfAyyg8RC7fikm', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'FoTxFjxf188ZMLoNmLNh9DkmszsimTFo1JBYbFcyr4Po', + 'CJb45n5hZNvb4XxPCdy8omJJDjVnj4JHGg9yWaaiPXUm', + '2GReFADgaVPnbhcnZNtZoe2KKmeW6shrMXb9Wb2SDdwy', + '6iqqRYdUHZ92UF6R9qVAawmfTWfQRyPkJwJe9civtzqH', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'Gm4ZnHiqbwAN5UQAHvcB4kF4tffmq3njuvgkXnpr7gFM', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '3Sw9fXekZxysXbLjwKMWKR3KZppkwK3V7NqfXtsVtizw', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '65QbugK8aNVV8dtpWhNd9Go326pf7zPWUSt5aeL1JJmg', + 'A5msCqg9FbJKfDqYyMnCt5PvqfcxQFkXhZbKUR4o36Jz', + 'B36zBbFMh1286MLaadjqzPvxKqovTJ8XzfT6eJfUHXdo', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '46dmRjzpfH5BxGTbSGsP8SoSiQnDqGZ5tTytAHw8MNo8', + 'n4SSAM4vAT4jfC7z8LLrWUd8MSsLNRJVKWjpgBxWYyv', + '13GGaN8kjbxqBrKQcNTYZy8DZpHMyZq2Rq5ZNZzMswPp', + '3vyDuiDq4zB5rA7WaBPsU7DPE5K7ubcUar7QSvQUn1pJ', + 'FZN7QZ8ZUUAxMPfxYEYkH3cXUASzH8EqA6B4tyCL8f1j', + '3Pb6zZy7NiGpqJhwY1mf8PDCvHY5qHp2hQkT7xRccBE8', + 'Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB', + 'HtYLEN9f13TgPSEdZXTTRHuxaS88SQAAmtxsjpPrn2he', + 'Y8GuCLeiwmxYnm79BBCviDX9MtW1gnSCEJozqX2iZdc', + 'BZkAvsQvfRgLmEiFQ9QyuxuieygYexwJuDV7coPyQNCo', + '2MN5ZGc2ubdMhdEKBN6BCJD4yEzsy73mv689RQkWDMG6', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'FERjPVNEa7Udq8CEv68h6tPL46Tq7ieE49HrE2wea3XT', + 'dhw5PX6Y87wwx3bNLJcGgXnzfHSENAUnB1625KwkcK9', + 'CEsDaNXFxpacKKBwtGsxQEQfB1b8cUy8bGbumgJVLMqk', + 'DZnDD5VE9bqKrsXgg4HSpWJf1vhTWL7zMnLJTLLBLqzp', + 'So11111111111111111111111111111111111111112', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'GvnZuqcbZLkHPsZGbUPi3QcW52pmdbWkgZiH6EvAeBsP', + 'Cg6FcNpV7vqzAMymS8xdQzAj2d53DrKr4Cu7UhG7LaxY', + '5EF26wfnByAStfA6QEH2QhzxQ7uq9Qac5EKGEYGwqnU7', + '7UbaaH65Mtxx799iuRSppaxvh11F38JJNzAqQxz6U6rf', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + '5ryGye62vRyKsrjfXz1uAPsHWsHLZqyQ6Gpp3VTNNgRN', + '5hQwiAa2FFhtVZ5TdEhDH6uiEF5r3ddA9txqMursdJay', + ], + authority: '9RAufBfjGQjDfrwxeyKmZWPADHSb8HcoqCdrmpqvCr1g', + deactivationSlot: '18446744073709551615', + lastExtendedSlot: '313572688', + lastExtendedSlotStartIndex: 176, + }, + type: 'lookupTable', + }, + program: 'address-lookup-table', + space: 8024, + }, + executable: false, + lamports: 56737920, + owner: 'AddressLookupTab1e1111111111111111111111111', + rentEpoch: 18446744073709552000, + space: 8024, + }, + { + data: { + parsed: { + info: { + addresses: [ + 'CFckctE3nFxLJNc2AeLjntSnTsNfNAuF5hLdbrpuUDY9', + '8LoS9qhfP6ijRFa9SpWxLts6n7jnzUEuDsfvBRjjSVoH', + 'zSAhF4qpy4kkg6Xx42CgL5XbCTuEXpXHtCn8na2iupt', + '7dLstRmg51YhbVghxWpTGB9kfBxi6MCpUydcKN7EWpDL', + '6dDm7tiKVY7o6ANmCEQVdAZHZ4UomCX1jut9AQKHqShP', + 'CbWXuYpHqHiX5HhYF3QNpked6DfuZrTiheQEdNALxGRe', + 'tyxedw665REMJvEm7A5RwDSdfZhtZ4GSoRLefK7p2U8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '6madiqRrvoa9frs26aqdhdhsG3BmgLHSHZWGpod2eVmF', + '5GJYUZ41Vv6Vh1DykaDQFNFhszpD5vw97u4c8SYnLvGL', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'ABx6RYS8WSrhrSP4P255CzzgNP2NxJR1gYz6X69ewLqu', + 'D5GqBaStSkGHyDWnv7tXoAgsKD1B6ZdDgRfiJQC7h7BB', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + 'CDwLouXTtamtG4qgnmM8v9rXhgGaeBoHmBSZv5FVwPTV', + 'Dw943QuxxvB2Se5etoSY3EShb8va8U7zRXHF52DLQcNA', + 'Bg9mdAYRpwkCYriVfhPdtzwB544f4HKvX2z6of6qccQP', + '92znhuo5mSBriZaiSfGNdiFoivLTxhgnZKUy8FMr9f6i', + 'FvZgh2aSjZHFsREiqiwp1SSkjEj4soem73LxqKzSredy', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '58f8QdsyJsRwAd9Ve6yXz1rn7LBeU5TfL4HHdD7D2a6U', + 'EGJx2NcyYHfAMo3JQVw4scELjYf78xtnYczriWW9YQ93', + 'ASThX5XiNkwyxa6Hm2UuzwioYvs21i9SvfsSMEDoQ2Xy', + 'DHvWUwV9zFZvvYdcSe7hF6RN2ku3YFEPounnweLdTp8S', + 'HnNx2LKSdrBAA8DdP3TJmiaHKD8npkZcRXQrMVLE7L24', + '9LxT18c78ocvWQzmTFGb6s8ViWDEaF38VRKJHKN3hKdD', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '2f6hSFi6PMNXZYeSim16WF8qmps4buEqn3918RF2fL5p', + 'E5Xc45CoPUA2H16Qeqy47KUyZaTiNMnpUBn7mjC3gQPp', + '21RpyMiYY3eL5MbScuWw5KyVrGhKkA3pisyx9C8KYfmq', + '8VZghjJYSpDTREFmrYpV6oTbSNQiSWwCJogosEmVJGJY', + 'kY3utYGWUK8RYaCEBcR19WJUFKvDD57tyJFbUUMphpG', + '4nCoYr19jwmZy8xVsGGtcq9EnxkbwHCLBCgwkrcEc1fs', + '6sEiLgDhR5QEGdsZHaPLKT4YHnzZB6Dp9Hin8VmgMdxV', + 'ENgL1kr79uDhazNuvgFjPAWyu6uPc8ew74WLH6KKiibN', + '9HJQW4erv7gcaWpqzyFtH8papkE6BtUrDdKb5VehNy7r', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '42CHNWvQyAsFUJfLZwexwjbapoFtAY7757AJ3cAtjfDS', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '9Z9jaFhNXhhVoEbpFvcEqbUxW4XCBkPHW94okjNVRP1W', + '7L5jLHPPT6ZcGJxyYZ9PEo3TnwW8EBFVHngK9ddr9KMQ', + '6fH2hkLJyPuvu2wAY1LM7FHVo6C8cc8jD6wQRgTNcBeW', + 'G2tfGNiALDLBREYjSWEyDvukKdrfaVYiMuaG52NeEV4W', + '9FZpstM8AErzcaG1kPcVaxCsbYX5gtN8rnpYheS2pdt2', + 'EeTH3yVay3GpxDSkVkvmJGcU2ebYs4ya3sC6qfxCTaqT', + 'AXHkNsE6DtigPzK4iDVTJyPczQfR8dT5rnsbUwCEMYmG', + 'B3bBfsDVGqtHGrtJZ9ekm13pCdYZmpWymCp2QA9jDjno', + '76jFbszozZ5KU2tKtDr3jRmoL9UBELJ6VysPiBnkUQQR', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'GoHqvTwgJ7QAkv3DfocKJM99HJs7QV9HGJKWkE4EMDjT', + '8AG23XCRGqDLqmSpPEvGBcUhuVwi7vyKSD4CneY7ecNn', + 'DQNvLesKSUPBH1pszoRKLYKyih49MiBvLpEn7Hubuvho', + '65isKt9YRFttRyexNjaCjZUY8zFccERuApsLroiMsGca', + '56E1AbQWDcTqcEJsQomzUw59VdWVNcp2A5fXCyzZTxyq', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'B7Bw9QUJLayiwpXA1fWFJTLV7zXzEEwCNjTHUi9VVWhx', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'DmnZn4sCjiscLzp636gzKDQSChJRVxwo83v7HFaW7CUv', + '8PofechtRi4xooMV4UZTpfB2yWtoucCUxGmV9UieSM44', + 'BcM58SyK8kmGswEovLMA9bU9tBYt9RCn74KsboZtM4NV', + 'ESrkWWydnvARx1cF2ayaSwuQfVXsPGE2tEQL7VWUxxmK', + 'Hh4a6P1uXEzYseQhvQUU6NEEEbxVQL45qGFsD1h9g5Y6', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'C4AJj4BJZVpmJH78CrsFrPPvi4g24XvUcBVguoZbC85U', + 'nNFP3A5rUbyoc69afSGYTz8iHNNofFYefG31z6CXyLT', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GEvQ46C6hzMFQBdiQnMuZzfXpEekuFTYXYshrDunsJ48', + '4tCCkUitpR3vMQDnG5N2NcWjZw3xtWuHonGfM69dyJaf', + 'ALeuPcB73sw3jSc7Zj6B4268PGQpQVM1VTe4RCkqNceo', + 'ADCWypuL6f8hMhCGyJLSAcEnZkskar6vGoCi5zXbMbn3', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'DUS33YbDGq7FzByeixaLCEDT9auojGbpuTgR7Mycuiie', + 'Dm3hYKxe3bkdSQVWgzZhwY7kudXVZRujLHpgEm5KLuHv', + 'Ee3DkrorhTMg36EuXnzrpR3NccPEVw6tykKHp4tKw1qh', + '8ZKUwvrSxdxujWGYeRjTmFEKmbggagyJL3zTTcBBttLf', + 'D5SMYuVPwR1G1K8wDDPQ1gsrhnYCoPNSWqd6rv5oLczy', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '59RSLU9nRewkt5yuUKqFYG9UDnJSaLKRnHPRxnXGG9cd', + '6AaBpMNnhaJAryvgmeoP9rUQvakZvdM3xWdvmct2vWKi', + 'DKLiRXTTnaTwwkVD2WJzUA6DSLvFYs7VXCAYd5byn1HD', + '9ZWNMxkh8CuUJb4Vk3kPXDZi4d3etfSDPRL1Bcvd2qN8', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '9znh2QarFTP3WhVwaNdN1Bft44tLhbQp8kCi1ZSmy5xQ', + '2Too822TUCUXGoHNhwyHUf2uYKFoS4vwU5BGVWkqXFJu', + 'So11111111111111111111111111111111111111112', + 'D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6', + 'HQtgECMQPQj73jQCuCapqRMMQAHAJwB1MKCvS4iro4i2', + '27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'GJj1fJZq4E8hamUpysG9QpqVEoWtaovDM53T7kQdiTU8', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '3rva4rJvNJceTxGUbgKAZ6mkKLAFTrRHdUZm4T1YW14A', + 'CB89yowGTRA81Q35b358nXLnBRVQngEkmuG6bo4oWnet', + 'MPgmfsiCgkorsMewV2ehcQpKuFwodWbGX5eEBnbm33C', + 'F91tQ9zpVuoLiafgFqbGFnTzRZ4FxyUSep6XEXJD5fXB', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'GitMrCiN2Noo2kJazstytHM2Q7dQstpeXCcX3SykhyNp', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'FA7HiJBaXL7DoNZ7pr1JckuHKEtdj1C3PZizgTEGt3KK', + '6sfnNQwrwBZX1W9y6z2MkMey9zkf8PedQTC7pWZhZ453', + 'DYjyM6BEKFHFgNxuKLByQN5asBDvJTkeZ3MD7DvcMfyd', + 'FypMnz6K75H7mgt3ckUa2EUfowqEQzCWBMuWyiHsg4xY', + 'EaXA3qJyoz9N3fFq6FiHdKT94BxavPB49aMNdmCjFc13', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'Gc7sV3hv1dp3wttXzHuhFmf5RmoPCyP5wCwuELBmPKGg', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '84KqD3nswmBfX9VAWcVkTqhwLTLRzMRrnd7881gYJdUh', + 'GKXEvGeTpS1H7Gkjjtv6x2ePEu2HBn4nyAeMtJQgoY3a', + 'BJN33TKPPvgiWAt5gpxpH9osbQa9P2LtjsvYgHjxiJbh', + '4xmuzktXtA8f1XdpCxLr9Dd4zCydAkHmS72offYCeXQm', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '4KNgQuY78CK5dhimcch7tPCtKUnyRHvVu4wPK9YKeiHA', + '2DdfgyJH1kvviww6n7A4AKXXTfh9ir8rTVAi86LK6ino', + '3vurUjjy1AixGafSpGkwecjGGxhSkNSjLy8LSvdf3qp9', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'FJpMLYQ6dn7Fcm5Li4kFLPtTW96gA2t4e9Bwd3T6wM52', + 'BE78BsrHcYvPrAhZhUmkdoVpJZ3hiFP3FdHf3kZemJaL', + 'AeyL47bGqkbsXbEp2b5Vdsij8g3mnPgbksaospBvvjBB', + 'DgSDsqPYJ7hLKeyBCP8okaDM5eYGEouL6Nba4LnC5m5s', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'CBTdJNMTYYYg5hfXfDgvP5buoyqiDSTWrKwMEbRcDKGq', + '3mGMRhmwk4VJCc8uds5ZKUrw258o7ayXzw9VuJKBhu5e', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '46tESd2DQEjbjShqcScVzqCFphaCbF87eh3Zz3UkUKBL', + 'An127YEsZoUF2pkrboaLUjSedfDHds61isxeypdUv3UQ', + 'EzraPqNi12sXjFdsihvxcaLxSWgECWzYmnBxujcXhwGn', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'Ash63fucMeEMyYMkaLs4qbznKUhoJiLQCj5rRtEkRNdr', + 'BPG7f4MvzFhpUXkVuxXK4mtQkRRESf82rQRkga7EtZ12', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GnospWqXqDHLe6ubtEZavGhZgiHCrEAEhc6B2J8dBi6P', + 'ARWibSvvXWeJuF1SR91re3UKCMvB9soWEwofHA9Jn6DS', + '7KZ881n27n1kspRodQfF9kGJAEkZ3vtzSytsgRuv6NLZ', + '8DsFH92Ajt5mXV2nCAHoc27vpgQNU9NukHFNwfdoHFg5', + 'QVe1QJNk3VCDRU2qBHbCA5D8aCKZKSYYJLTtJ81PDnu', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'EbT7hsadRjXmgU7LGREdXh3ghTrpdKvH1aRBcmx7oZgB', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '8S4UqcP6LSEzSXchozJXeKqnmuHcAQxjAtxQnWeC1u4C', + 'BUgfwXEBgDKMWkrnDvewpySqQNLpoAtSm96nKrfb65Km', + 'CQp7ttdHrQmRwwSzb5nmk8zghWdzzfSRvme4VDMp5TA9', + '4tYKZJUornqF8BBEpfdoVa1DRHbHmEDx1KUFXYxTbf1i', + '3nBEVwhuR24Rd7evruEY28ZWe6b8Zn6XMmQHrMD5aDDq', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'A4jiQNXY6JJ8FBXHEKWk7tBuGprpfLPY3bUGPzwR2PZT', + 'GD9F4QFXtFHWKVA7Mgbgkz9LvboHkNCwgyQr4XZUY68Z', + 'J7KsqbFuTS7phH4JxVxZRRsdyuSMbr3UKMog49NngpoD', + '3uicMB2qBLFaGQpsAHQAqDqNFtfPSYZqs8vBN27jbzJw', + '8n41sUXwes7946NJ9A831ZN5qRuRezFG1VXu4CYVi9Zq', + '42bEzkqtaY9VUdoW2CKa7jRjGk2mo3xeRtDBJtjtwvai', + 'EwtxNhjvL2kXmXcrRbDnBd4pu2QcCSXjzoYC3T2vjFWL', + '3Y2jhP39x1RTtcPx2RosyFf6LFEr9sUy567yCYyCW6Eb', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'C5d5fbbeTjwdJrct47mQsEjnsxSkzoWRCwX85tKqkpTp', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'Edqghohcoqh5FV8UtcwtSrSK7yGgfouuEGh4Cs1phdCH', + 'D42nh2xFnv8mhBY6z55zPMhfwpKWnSo1mxkgovcr8KSp', + '7wfUbzAVUjFjSoMLmMjpa7teyzU6Y9AVv2GhquhTG4xV', + 'DDrqV5TxChkt74NkEaRfdNzuEzCVVNCJeByqnx3L7ZSE', + '8zEfhgSr5m28nVrxsRnfctYpkEjq5qShvbsPWR9CJW9B', + '2xbmN2KwtAzNyaqTF2S3J7yZ8hTubMC2RmUCL27b2AA6', + 'G2UuTk9KTdJbAP9Tch1VMTWP75P2AK9YGmUm2PNj1Yu9', + 'AFbQVSH8E2oMNjBy3cBg1Z9Y59FT9LxyrkYNTiwUKiNh', + '7ji61tqjspJYjPyduDgkHqSfb9NRQHRJUiYqexqjKqpx', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'ETsbhasMUEmnDGn44m7zxLKuwggByiwmyzbYceCDdn4K', + '3Zh1SVcQC4nQnVFur1x5HEESm4pbbMT57MLeKETr9iZw', + 'A583jFNBQtUDmRrYztYsfwygyjjZVYJoyUeLLtqYvF9g', + 'HEzRDQSg6GmKEVV4FfNSiuyNcavEsP5mujt4fqSLcLK6', + 'DfEWbRFxSL2erCv3X4tphe6kJKFtCn8aa6gyKSvqisYr', + '55TT4ir3n6FiAz22efZDbLeMHAW7NzL6VxUeBXRuY5hj', + 'E7DQm4Y8cQ43ECdM28JoFR6EiYsrL9QQ3apfHXt8n6oR', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'AJUJajxemTGrg9HHLcXsnE7eCdniD4UPEnmFTSzJg9wR', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'Aif94ozDyZX62EvVS7Wx9nMHxUjA6iVxzNy3cS4kAdZp', + ], + authority: '9RAufBfjGQjDfrwxeyKmZWPADHSb8HcoqCdrmpqvCr1g', + deactivationSlot: '18446744073709551615', + lastExtendedSlot: '259660292', + lastExtendedSlotStartIndex: 169, + }, + type: 'lookupTable', + }, + program: 'address-lookup-table', + space: 6168, + }, + executable: false, + lamports: 43820160, + owner: 'AddressLookupTab1e1111111111111111111111111', + rentEpoch: 18446744073709552000, + space: 6168, + }, + { + data: { + parsed: { + info: { + addresses: [ + 'A1GNEDJho9p7YhLRKqTSkoWgZXMjtDGPm9iHF7YWXcBG', + 'DM9nCDsq7LS8batdBDEnGh9kqWPg2GTVTf6cyj9A4zns', + '9n5JZGhE31tWh2guMgke5Xhx178oMUexCjkA56E9RL15', + 'HdSNeusV4nauLkUibxnT1v7WvAr1b3YNU6roq9TgYdGh', + 'DbTk2SNKWxu9TJbPzmK9HcQCAmraBCFb5VMo8Svwh34z', + 'AmtixF5nt1oc22mM3ANYQju8rpPWNghbaB6iNHazcxwh', + 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GqmRhwkSsb5XbLe8vFob82BJdEv6n4ATfTbqab9q1mR5', + '2LNvcmeTwEtWhtbFiNWcQ5SAVa23np1syTrgjYeayE83', + '27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + 'D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6', + 'ENPNvRKM8LuNzZNpQyVaBjWMqqqZx2mQukUQv4gfW4Jz', + '51otFjbgcbGZ9vrUXWdmNe1ckPM14kRnur8yAuxY2vpd', + 'AauSbyE34GsfndXiYuURRcFFMZFLxuwqCKwriwicvonP', + 'So11111111111111111111111111111111111111112', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '5TfpGKkE5V3eMV5DjTytDehz9Qvgsd6aJLuHY2XeHvCW', + 'Fibvys4za2d8ZARR6obaoUR1LnjsCrf68D83DrzmheCE', + 'CmKZoNTbF1S9ftBCjaU3HQRYa4H7qKD79TWh5ESRpu53', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'DHWKtcX2M9FJFFqjM2FEnCLv1wYUx5q6hZoEdJmdQoF8', + 'DvJsDWj2c9tT8Kgn75FAZ3Utqz6BJFcMWwFUHArjKBxo', + '8QVCG3DRv4t3x7iEW3GgQvaT9CJPZTVUDvNuZpeKPnFw', + '8HnhxpM6XfJPc74fQCAhwZp8zZdzDoUeGUKb5MnkXfSk', + 'So11111111111111111111111111111111111111112', + '9czX1aovJdGNTnXgitQw5nJYGgBNJcZWd4Cdebv7HpZE', + 'CXG7hkrDrRm5Bkxbora8Hppfi77h3coZSmg8peYJAgDr', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'So11111111111111111111111111111111111111112', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'A1biBmEPiGbDdnWo5ovMe9KWJSPS3diLYka5H5SvrKDL', + 'ARhEfHoEthRdcsA6RkP2xPL24q6dQF7Q8SGj9eTss7vf', + '3s7YWCWfwbU6fR3tN4uYJVaFmTpZ57sxpqvdWkDCVEQ9', + 'E3dZB38jdxnFJaApcL22mCMEvsC4YSmuFX4TQpmdNL2n', + 'G5nEEJEeK5RGpRZ2Y5sMHc68FNgPa86rQCe9c1Kv9LJt', + '3JSFeLDG86kujmpKH8guTb8W3s5ZRdP14mq31YJuuUy8', + 'SZHStCkFYZMfe59H73shSXD8o9f1rWJWrngk3z1nwMN', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'AaE45uSNKqyiVJVGLT9FXBxYXPt3VToqCy6xBY1uV9vo', + 'AtxAuhcnngWMnDiJYVahje1hZAPTFi7RRiQ6KBhaZXmf', + 'BtDiVzZr3fabcZH6GxHovVdizXhDzeoxPTKucTWpPymw', + 'GXaNTa8nskBxpZHRLpAR8bmM1FdLychNsvvAMYinArJW', + 'B1UrmUrSeuCgrqsMCJGyBzQk5nGN2dcBMg6rUjYcsV96', + 'Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB', + 'Bmsyx1A8ZT9VbHjhT2VrAhAyKoEWBEep4URezEPxGF8E', + '24Uqj9JCLxUeoC3hGfh5W3s9FM9uCHDS2SG3LYwBpyTi', + '4LqGbVv6QMjbCqH5PgrHeeD8phiDh6QCfjEkUPGUwoVs', + 'AVM6dJrcFRxWtgjjQ15TZtS1YpiSB9CwocZu9ELepEph', + 'uegtCxsT4QrpHWwNkzW72YdmULhXJksrqHGbLBnK8UK', + 'H5XaJkDrbHa1yCPGixF6KTRAPpkXBaNpE8Z2R5Hdn3Fw', + '4NfdyqirbykGHoDxVczXmdLLjNSTs2nTbgjWCF1bBfyg', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '78nDvTMqjP2saeRdM3T4BR8rMsj1fLvAdRroRye7kEtw', + '6zA9bz4tDk7AAMAzvfPCZrZ9tqG2dEnrH71XcMCnmpN9', + '6FJnPe8s6UQpzbiPLnfqRuBPsBcnZM5ctcEg4NNostSK', + '4A3sqP2w84nfMDMXCiWndueTBVWxs3m5mJW6QH7Jmog4', + 'GKNHuNc9dPXesv4dLKog5SNkuF6m3XwKinJq6LcDpT97', + 'ALdWuyW4AjVZ7V7fan31aYwszg54fpzkBLwZPk3LKrpf', + 'mpKCEn155LD7p2yLECEMKuqsUQDURbgGXMPSnBrkJMN', + '7sxv8ysjvosMqnXQgGRLAkf3ae4vWinBQBUozY8sH5T9', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'Hb8SctAgs7ASczM6JJR8VK6UZWqrNLBgrdGqEgyrYe42', + 'HQ9mnZmW5HWBVD8KGwskGdLEm2Essgxw2sM9Lc4WDbaG', + 'So11111111111111111111111111111111111111112', + '3qR8RzMEjwnLsKxoToqP56zQat9LC1EQrgQm6ddoA79b', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + '5kWAvcKqjKpT4f39dRE1uyqFy1cMYsJiZGcCwFEbXX5R', + '57U6WJjPcX8wEVJ61rGPo1ksDVgThSgVDxsK9KssFzQ5', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'DxY5EmRDN2Tf4TH1A9kpzzF2Zw72FHAYLETCYWSBpFeN', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '6wRGKKGZwSuhvDM4dQ257YZFb747tmy9h6oD2xAZR2tk', + 'AhjAsss5YjZg2uUSeV6NzRxKwGZisvfCLstin1KAGLML', + '9xTzDiQ14Bt4aainMRZMYqwQ9W3q7ijshvjrf7LRZ5Ve', + '93F9tj5W6BDqTb4BMb14kznGrsCqjmSvygcZsWxSQRRS', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5nhbzQwXTA8v69qfPGBaRbm9jBi9RKbny5Wz49pnGbMb', + '2RhW4mPnHmUWL6QZbRkZcJSUkf511Zc6y5H2LANUmY3K', + '8DcNRcFLJ71Ag17fM6hwcBg5qBYqpK1Q4jbpXgebfo7G', + '6y49K5N2YQYLqvnyUQVKP68rT12uhX6F1GCs7SbQdhi9', + '6mDSZbnLMLWTfumzEK6pUeJxqrN44Bg8EQSx3D1JGp3m', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'Bw6aVQyHMAwWrUdM9dcCUioRpTNwPxQVEz3Pd1f4Tdd', + 'B7ufeVuiRyPY3F2B5ruoKzEtViwLJDGdFzEBBFaw1kHD', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + '82rhM55LtMiv7zM8y6fWYDxx1KajJuxe3jxEraJJcxqJ', + '6TzYvde8qHsmFz429BHbnyQ8WDgmWSknBwxiaWJbHcuZ', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'A8eUqBMdUA3Kj2SDv5WxCFD5Etu7UhpEBW1Zp8uyYGTN', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '4n4ALXaU8sq2BX5exEEsKaJg2eUEndPvFAYHCA7vVmg8', + 'HXoaMEFgGFsm5428KicZNjvkyNkTZ9ELae8i31BCtcPR', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '3DMirscqSmuRgGnKGk7zxA2a64Z66Yg71dvhhhAueE4i', + 'D2wdhm4rKUhuASw93yaWYGPDiKUwApCiJagN3BUwmwoU', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '6iE1SRm4pgjpY1VYdTgiXhkFwfLjDs7TRC6mmbdJeAay', + '4J6SHkT4sH9sq6bKAxwix4cNNFMDuVTNJQz6LBRzdCXo', + '8St1UswfZkZZNTw6o4wjTsMq7Ppu9M6aU4zpbdZU4xWf', + '9Pen3uvMDDxqaQboCJhR8thPWcPHBWVbbnzE2jnBtUUE', + '4WCdKxw2an2eutnXghTBAiYqxvdYjLDaPH6eG8U4hZE9', + 'fyckAmrXWg8oWGveGSFtNFP1BMc1aTXugYXywMYYEV8', + 'GF9iEwA6fGu85V75YB96SDjN4JbpoyJNknE5cFHBKGu1', + 'BQDVxg2XXvEDAyHAvXg2pnzzwU4ib8WcG3aNA6b4rJ2M', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'EBzESs2LH6hmpueTQYUhHsLL6Eo48C1P5uvcvQVPYmBx', + 'GSxczUZaEEkjWYvMZu3iJkDHYxBvFqftp6mHHGKVoQ81', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'Ge8M6erv4ivCoW4vgjFvrRnRYgTthHEVMNKvxGoawjdK', + '9rHzJ9JJDis18km9QgWjZpHv72SHDhPoVEeQJFzQiujJ', + 'FmCwK4Qtw8TgdXnCfyXuGKob1dcvUVVfYwd8DZDWDZoH', + 'GTVhaar5esTCFT6U6FK8QNdWrhTXLq5gxomAjgtJeVb3', + 'CHsJEjLXKHfTyshFvh2znSBvUmut55weVNEAoMwRgUWd', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'HLLtnTBFYdyhbZBCTXuAaqj4sXkT3p1nGKruphrp1uoE', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '2LD5xMkcUa9JViyaTJCWniHT7wvhwjGNgSPXCPYnPFny', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GqCSpjHpb9fTijbgdDzAJ9BEB4UmyrNA3QkF3QVxRyDH', + 'DrRE5UcD9YEKijYsoTKM48vCyMUGGcmiPbgaps5CJWGP', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '8fxdwExb3YLtM6zGc88rBntikCjsQsbhpTxGvtXxKqhR', + '2LnJMhVheZfcYJfC52CJqryriPU8CzQBb2dYKpwYu7zM', + '89mNJuyvF9XSm3Z91WtQhPd45f3NnDpVD4Qfsw7S14Xn', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'ADYKrgeaXxhXPkHMHtyFaLe1bK2A4Zh4pyskTHzXjMQk', + 'D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6', + '3krWsXrweUbpsDJ9NKiwzNJSxLQKdPJNGzeEU5MZKkrb', + 'F95tMfZd4ytWRsd2Dd7K4b1oR1qn78VeMeXMKF9qz8bB', + 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + 'So11111111111111111111111111111111111111112', + 'EZ6LBvFetjX45g9bZZiskMycvNWJJiCjgWiAHraZU9qK', + 'BGukoBChrD98vuMjT1zwLGkogxHyS8WJC17e5CXrLc4B', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '7jpTNEfmCdqsppJEwdGRjopR1jdf19wJRJkJUnRoumr9', + '8yRuPgZ3kqLtQAho2hN8bKQnXLRKo7hqf2rUohdFyTrw', + 'CsCkHYE4BxYRPrP1eaFi4LzpqR4Xbv4ryNX4SJFsVLa1', + 'zuxjojEFKMTp8qhCcaq8b5JNY3ZzEuhC8TQGwCS5Dsw', + '5E4hVU3A1iB69DH6WPhw4HBf44TmpucYevQ2hDvUCNee', + 'HHLyTHK7psZXoKNa825mAgwasD4HbmvdGb2QP7PxFzKG', + 'HaeQwKBozr7HAwAcFGGTdS84HiavrDodUhbsLxdfmvDm', + 'FrMBwEHAQqo8j4S6vpgLCuFxLHXF9zpsz6motSgLNHqe', + 'FE2QXDhcQV4i62UcqDgiLBnvyGsgBpPSDzZz3cZea9Ms', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '8f9xTTYLZ1TUCJr4kJ2A8jUptX4otYUWD4vfj7VNxzGE', + '2JuqzSz1eizHtCx9K1TnevMipCz72ao6gPMz8b9NDwPS', + 'EQao8p4XKkVJfryRGTDEcUQ19xebwYbLAQgjfcF9BD4Q', + '3EkmaPihawj5m6XqFQ76aJaMdwq9iTa8qJBrPr2hxRVW', + '495CGhc58RjrbykgN8ic7ao19gaqEPwM7LX1s8LePxfx', + 'J9tH4RBu6zV2pfVqoL6gS5a3TXC4ULbGGZ5ZTxDAuRdR', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'FCekcQrrpKGDjYAPbQNq7XeuxUbtHRbNnTchhEDP1j7Q', + 'HwdFE7W6qg8HZfBhk2TeUyzFkQJPZswnW3dUCy4xg5oP', + '8Y9BT8iV4HNvX3Q3JDxrWyhHLFTp2YikqvDcY9GZYeWT', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'B1pbxWN2tMTzUuutbsVewWEgEwGSzMr662j2G7o4vKN2', + 'BRE76tdwEEBkMAziJ7YP6jnf1eQmzWRxtgZUDEaJdVZJ', + '4YSsr3tFHkaJx1JSb9YKQvseGMTFExEJJgiH8GZVU1jP', + '6u5psGPRHpZxXtXLrfQmzeuYoRAjbSkWxLakQ2FEcWEE', + 'As4bH2cKVvNA471rBhNhXNZBs1CWHTZLFu7WmsgSqh37', + 'BQnUSFUHCg7m99WfspoWbfmvg37hyqvcvjHfjiR1t397', + '4ycTDowJdbT3RKUFvnNsMKwdrtRM3JJFLWSj8J3wqWt1', + '8TZXYHA5eHASGNELomc11k4jp4AsJDhaUcajf3acdtz4', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'Gb7V7d3RbGPmkRgyadiiyjeXbdTLS7QwCtsg1Jq2VNtt', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'BDrkSNgyFzNfB1WB8GJJ1DWZxAsrkp3dxnwJNx79AJXn', + '6A6gxwSS9EZzSyJfDJMKKFsSen8fo26u8NRvQGDgLndZ', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'FyMorckhVbCmdn1mex4oQ61ZzZxPbRAGH9mBtNCh8Z4N', + 'DocZGVvZGpQzPbCf7rPpyHGFS4Sg1ntKXh52wLxKBjVW', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'BZH9sjCuHuro11hwTR85wpEGHxLbAiTgBmTPA7droECZ', + 'RbBvqJKpiUFf4f1fxxgmksY1fwCdLAwedzgbskMTwJu', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'D81GexnufJBj3Dn7k7vnQQvxZ1nEuPWuBdVAFK3wfn4j', + '9DCjgFJ31eJgBLFs45QwZ7UnHSR5qXqgG6T15AMArzVQ', + '6gsPt1mcdmpLxbt2Y7fe7sswVUXMj74MFTHJ7eL2gAAR', + '29GS86ps73vsgcD6aZL1QUVkKoLL1bF5z2ZY3krpDFwi', + '4LpUEcjZvpAsct6KBB6Sebss5pVNtcBTdxbBvJvHpLVT', + 'Hi2w5LXci3deZK8yR9RJE1bMScYQpzCYjafHgMWnWz8Q', + '4F92FpEqtQhjL3BPfqgbnePPrRwyUb8TroGUvFQgqW62', + '8GVD5wLNp1AM9AAJ6FXQmxyMwyADBi3oJrJDF6WwBJdj', + 'CywQujiyw4x2m6aSa8adSRPB4Vzg2eqZaKMqopVckurj', + '4yjYs1mFYMoTvR757vVe4Seq96RHiLc3Gszqc72Nvfmd', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'F99aqCKJJ5cvrue63gHTEZMgLXjvvwACk2MWjqJNyQng', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + 'J52cLEbRWPFkesjwkNSNDGsTDbF6W7BX76H1zS2A4JYa', + 'So11111111111111111111111111111111111111112', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'AFtBAuw2NKUedd4VNC1RVueiJLbxA1BWXE7VbhC8c1b2', + 'FY425xvBqpZwztzz6K9YtBsER1qfnKZJU4HETBTy64v4', + '8tEbUaVBirXoS5TV8oS3FBXKBK7bq79kG6q4TGgodhta', + 'rGDJR44AKhwCrAPMyUFyoJfmWU4WZA92tomDScDxMnb', + '7apsjwfm9itrbedBHZQ15i1m95nbkoE1hWAsSAyoLGzo', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'GurPyAasX39tqvJeaHYg7y72FjY4bvLUJzyrG5bWEreV', + 'AZ9DfabFFppRDyrKVhr6oJaGBuBC8UtLWxUXXjtvoXSW', + '8ueC3wXdwUq13us94xpxrF61HcRfSN7JW7RwR95osaY7', + 'BzkvLs6BA6yzJxnUxX5ocaxeoTnHKXoq9UtPKwf8XHTn', + 'GWNaaSEyf3jnPacGAVKXehAhWxhjPQzYNriTN5h5VK36', + '91aSA2N7ou8CV3sahKujCuKdaY1en8Raq9xkL5GSKCb6', + '3MfzZS51nZxUqWjCFS3qu72hK4F4NqoieQz738qrKFbH', + '66gReYjbGosKdcNK8bNirXKtc52bhmZfkNiGYqE6DLJ5', + 'FvUVDSsgmitw9Ds99wHCw66nmDwfnB2poYVZ2p1vpJVz', + '5G9w2gcRyHTccHokXnReJN2JAyxtmDGbFTHN9NYRja96', + '7DgeqtZX5gaTbHkwK6yiUshc5FETuQSFpjLLPijTaxmR', + 'Fj4W2NsZB5ouxvSVpFVC2vC1CNrspYt3ivYZNTBvkDUs', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '9rZkjrx9BuNmEBxLhAczQVEiXQdxL97MhVPws3bnQWXC', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '5nSu93fcPtvd7n4FcWEHiHK9TXn6ZTMR16ASmaMpFtoo', + 'AFz8y4sFekT99FZjTTvVorxBUP9fS5wfw2mguYq9Bhd4', + 'Cmb3KmCW63tB4QM19Q8Ypv3k1EcFG3HK7jmq3F1NTW3F', + 'HkKW6HExpdzS9iA8v1fUvKLn8BzTm8rgF1WNEXSAGXsb', + '4jXpezvhAE2QWpdgM64zr6Ckrd1XViuYeZmSrs3vvCWM', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + '8dicvwxjmLmdCJpd6unKwwnHM9LwSocgBPnuS8gH8pPo', + 'BTbZegsmz5A4FP9D25Utmt6LcGJdAyuywLRfsRvMKE6K', + 'So11111111111111111111111111111111111111112', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + 'DsaY1jZBgZ7uW1VqnWyemNZ5joXCexrZufraQj5w7y7H', + 'CcU2eFvEMFBmcLpJVg652uptvu5P9cbcSYb6GjY7G7nQ', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'Hu7bXKh9QFn2HhfMNhFkCkVZXGPFeDmDGAVbgHT6RkEA', + 'ANxHxacUxKi2hZSPWHTqiRfLAiDJ4CjQkWzbFp5GcLNV', + 'CJ38aKCxoZLqERnLdHQ4nnGJ4jodPCDgnkfughoZhXnU', + 'FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X', + '6rKjigJidZiVo86CgWFtCfn6SY5tWgc84gJPDGtfJnjh', + '5JH1NiP2TGVqSRGETgxZqBsHjZw4UKo5rscepQ8BmrsA', + '8zxAdA3qkguUPmoAb5J2rESxYV64snHGbfrPo7xV8ium', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX', + '3JmvyifQr3CDmcre2DpvBQ1Th3f7oanVMoR7adF4imgj', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1', + 'AGa5hU3pvvVk3WCfGfdQMRKA7Cq7qsWiMPZvkAjBdfvK', + '85W21p7LN43Hr5uASghbTETPLUkGXi6DL2MocCc4zmwa', + '7vD4zidinv5WxmcK5jQMEWNNu9LteDbkAS9VPwcYqhBs', + ], + authority: '9RAufBfjGQjDfrwxeyKmZWPADHSb8HcoqCdrmpqvCr1g', + deactivationSlot: '18446744073709551615', + lastExtendedSlot: '261565224', + lastExtendedSlotStartIndex: 233, + }, + type: 'lookupTable', + }, + program: 'address-lookup-table', + space: 8216, + }, + executable: false, + lamports: 58074240, + owner: 'AddressLookupTab1e1111111111111111111111111', + rentEpoch: 18446744073709552000, + space: 8216, + }, + ], + }, +}; + +export const MOCK_EXECUTION_SCENARIO_LIFI_SWAP: MockExecutionScenario = { + name: 'Lifi Swap', + scope, + fromAccount: account, + toAccount: account, + fromAccountPrivateKeyBytes, + transactionMessage, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + signature, + getMultipleAccountsResponse, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/noOpWithHelloWorldData.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/noOpWithHelloWorldData.ts new file mode 100644 index 00000000..bdab9a28 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/noOpWithHelloWorldData.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CompilableTransactionMessage } from '@solana/kit'; +import { address, blockhash } from '@solana/kit'; + +import { Network } from '../../../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../../../../test/mocks/solana-keyring-accounts'; +import type { MockExecutionScenario } from './types'; + +const scope = Network.Devnet; + +const signer = MOCK_SOLANA_KEYRING_ACCOUNT_0; + +const fromAccountPrivateKeyBytes = + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[signer.id]!; + +const transactionMessage: CompilableTransactionMessage = { + feePayer: { + address: address(signer.address), + }, + instructions: [ + { + data: new Uint8Array([ + 72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33, + ]), + programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + }, + ], + version: 'legacy', + lifetimeConstraint: { + blockhash: blockhash('GmfR6QBrCj6ypdyrJFpBNUjUMZaTazXHG9bVczYAWsVS'), + lastValidBlockHeight: 18446744073709551615n, + }, +}; + +const transactionMessageBase64Encoded = + 'AQABApmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N90BUpTWpkpIQZNJOhxYNo4fHw1td28kruB5B+oQEEFRI3qUEvBqvuwVnMsfmri6p3ESdGReEuAbbq6sJS3xMsdsQEBAA1IZWxsbywgd29ybGQh'; + +const signedTransaction = { + lifetimeConstraint: { + blockhash: 'GmfR6QBrCj6ypdyrJFpBNUjUMZaTazXHG9bVczYAWsVS', + lastValidBlockHeight: 18446744073709551615n, + }, + messageBytes: new Uint8Array([ + 1, 0, 2, 3, 153, 176, 2, 143, 157, 158, 175, 50, 134, 226, 145, 237, 10, 87, + 130, 63, 185, 200, 241, 76, 205, 92, 21, 136, 75, 157, 88, 79, 89, 248, 223, + 116, 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, + 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0, 5, 74, + 83, 90, 153, 41, 33, 6, 77, 36, 232, 113, 96, 218, 56, 124, 124, 53, 181, + 221, 188, 146, 187, 129, 228, 31, 168, 64, 65, 5, 68, 141, 234, 80, 75, 193, + 170, 251, 176, 86, 115, 44, 126, 106, 226, 234, 157, 196, 73, 209, 145, 120, + 75, 128, 109, 186, 186, 176, 148, 183, 196, 203, 29, 177, 3, 1, 0, 9, 3, 16, + 39, 0, 0, 0, 0, 0, 0, 2, 0, 13, 72, 101, 108, 108, 111, 44, 32, 119, 111, + 114, 108, 100, 33, 1, 0, 5, 2, 64, 13, 3, 0, + ]), + signatures: { + BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP: new Uint8Array([ + 42, 147, 159, 63, 47, 151, 209, 112, 102, 32, 45, 149, 126, 224, 34, 52, + 4, 92, 154, 222, 109, 219, 132, 255, 198, 106, 93, 62, 2, 43, 62, 57, 82, + 112, 133, 209, 230, 23, 12, 95, 109, 102, 10, 110, 46, 110, 166, 170, 147, + 98, 11, 9, 22, 182, 178, 138, 239, 49, 114, 94, 194, 121, 34, 0, + ]), + }, +}; +const signedTransactionBase64Encoded = + 'ASqTnz8vl9FwZiAtlX7gIjQEXJrebduE/8ZqXT4CKz45UnCF0eYXDF9tZgpuLm6mqpNiCwkWtrKK7zFyXsJ5IgABAAIDmbACj52erzKG4pHtCleCP7nI8UzNXBWIS51YT1n433QDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAVKU1qZKSEGTSTocWDaOHx8NbXdvJK7geQfqEBBBUSN6lBLwar7sFZzLH5q4uqdxEnRkXhLgG26urCUt8TLHbEDAQAJAxAnAAAAAAAAAgANSGVsbG8sIHdvcmxkIQEABQJADQMA'; + +const signature = + 'rNaqYcw7VsWYcjHALq7nqmSXhYoUQ87hWWakzdztfsny5UGWrKYoLNwFbw9jADtAhfEdXNwyBAWn6MQkYt7UXcb'; + +export const MOCK_EXECUTION_SCENARIO_NO_OP_WITH_HELLO_WORLD_DATA: MockExecutionScenario = + { + name: 'NoOp with Hello World Data', + scope, + fromAccount: signer, + toAccount: signer, + fromAccountPrivateKeyBytes, + transactionMessage, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + signature, + }; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSol.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSol.ts new file mode 100644 index 00000000..2c8c567f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSol.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CompilableTransactionMessage } from '@solana/kit'; +import { address, blockhash } from '@solana/kit'; + +import { Network } from '../../../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../../../../test/mocks/solana-keyring-accounts'; +import type { MockExecutionScenario } from './types'; + +const scope = Network.Devnet; + +const fromAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; +const toAccount = MOCK_SOLANA_KEYRING_ACCOUNT_1; + +const fromAccountPrivateKeyBytes = + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[fromAccount.id]!; + +const transactionMessage: CompilableTransactionMessage = { + version: 0, + feePayer: { + address: address(fromAccount.address), + }, + lifetimeConstraint: { + blockhash: blockhash('8vMXV3ERvs12BY8w1nSHutzwwMptAR5UvUSq5pH2QYsK'), + lastValidBlockHeight: 18446744073709551615n, + }, + instructions: [ + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 44, 1, 0, 0]), + }, + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([3, 232, 3, 0, 0, 0, 0, 0, 0]), // 1000n microLamports per compute unit + }, + { + programAddress: address('11111111111111111111111111111111'), + accounts: [ + { + address: address(fromAccount.address), + role: 3, + }, + { + address: address(toAccount.address), + role: 1, + }, + ], + data: Uint8Array.from([2, 0, 0, 0, 64, 66, 15, 0, 0, 0, 0, 0]), + }, + ], +}; + +const transactionMessageBase64Encoded = + 'gAEAAgSZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdN2zrulHQhiUvVcoUaPML7MFkgDu9PV2PudQFNTACzusAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAHWszLmyDo8VIk2P/sVmDUn34YE2+73fS1kNLCNojDEqAwMABQIsAQAAAwAJA+gDAAAAAAAAAgIAAQwCAAAAQEIPAAAAAAAA'; + +const signedTransaction = { + lifetimeConstraint: { + blockhash: blockhash('8vMXV3ERvs12BY8w1nSHutzwwMptAR5UvUSq5pH2QYsK'), + lastValidBlockHeight: 18446744073709551615n, + }, + messageBytes: new Uint8Array([ + 128, 1, 0, 2, 4, 153, 176, 2, 143, 157, 158, 175, 50, 134, 226, 145, 237, + 10, 87, 130, 63, 185, 200, 241, 76, 205, 92, 21, 136, 75, 157, 88, 79, 89, + 248, 223, 116, 221, 179, 174, 233, 71, 66, 24, 148, 189, 87, 40, 81, 163, + 204, 47, 179, 5, 146, 0, 238, 244, 245, 118, 62, 231, 80, 20, 212, 192, 11, + 59, 172, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, + 186, 114, 195, 155, 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, + 58, 64, 0, 0, 0, 117, 172, 204, 185, 178, 14, 143, 21, 34, 77, 143, 254, + 197, 102, 13, 73, 247, 225, 129, 54, 251, 189, 223, 75, 89, 13, 44, 35, 104, + 140, 49, 42, 3, 3, 0, 5, 2, 44, 1, 0, 0, 3, 0, 9, 3, 232, 3, 0, 0, 0, 0, 0, + 0, 2, 2, 0, 1, 12, 2, 0, 0, 0, 64, 66, 15, 0, 0, 0, 0, 0, 0, + ]), + signatures: { + BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP: new Uint8Array([ + 99, 71, 118, 0, 107, 144, 42, 175, 174, 75, 46, 121, 60, 40, 175, 238, + 109, 104, 232, 29, 253, 249, 18, 241, 53, 26, 190, 146, 185, 248, 200, 68, + 71, 127, 155, 221, 252, 116, 1, 211, 62, 10, 250, 75, 67, 199, 247, 199, + 239, 77, 7, 60, 45, 179, 174, 129, 11, 246, 223, 168, 249, 0, 204, 2, + ]), + }, +}; + +const signedTransactionBase64Encoded = + 'AWNHdgBrkCqvrksueTwor+5taOgd/fkS8TUavpK5+MhER3+b3fx0AdM+CvpLQ8f3x+9NBzwts66BC/bfqPkAzAKAAQACBJmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N903bOu6UdCGJS9VyhRo8wvswWSAO709XY+51AU1MALO6wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAdazMubIOjxUiTY/+xWYNSffhgTb7vd9LWQ0sI2iMMSoDAwAFAiwBAAADAAkD6AMAAAAAAAACAgABDAIAAABAQg8AAAAAAAA='; + +const signature = + '2z8EPNFosL7kfjTsTKWRPMgH7G41bJW22qNtRa4vNSHEmBG84SSr4yjqAFsp82gc2tEuc6wtamiof7BMwfeZzewB'; + +export const MOCK_EXECUTION_SCENARIO_SEND_SOL: MockExecutionScenario = { + name: 'Send SOL', + scope, + fromAccount, + toAccount, + fromAccountPrivateKeyBytes, + transactionMessage, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + signature, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSplToken.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSplToken.ts new file mode 100644 index 00000000..f44e4c4f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/sendSplToken.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CompilableTransactionMessage } from '@solana/kit'; +import { address, blockhash } from '@solana/kit'; + +import { Network } from '../../../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../../../../test/mocks/solana-keyring-accounts'; +import type { MockExecutionScenario } from './types'; + +const scope = Network.Devnet; + +const fromAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; +const toAccount = MOCK_SOLANA_KEYRING_ACCOUNT_1; + +const fromAccountPrivateKeyBytes = + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[fromAccount.id]!; + +const transactionMessage: CompilableTransactionMessage = { + version: 0, + feePayer: { + address: address(fromAccount.address), + }, + lifetimeConstraint: { + blockhash: blockhash('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + lastValidBlockHeight: 18446744073709551615n, + }, + instructions: [ + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([2, 186, 18, 0, 0]), + }, + { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: Uint8Array.from([3, 232, 3, 0, 0, 0, 0, 0, 0]), // 1000n microLamports per compute unit + }, + { + accounts: [ + { + address: address('G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA'), // Associated token account of fromAccount for USDC Devnet + role: 1, + }, + { + address: address('CSq2wNLSpfKHCdL3E3k1iksbRXWjfnD87b9iy35nL8VP'), // Associated token account of toAccount for USDC Devnet + role: 1, + }, + { + address: address(fromAccount.address), + role: 3, + }, + ], + programAddress: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + data: Uint8Array.from([3, 232, 3, 0, 0, 0, 0, 0, 0]), + }, + ], +}; + +const transactionMessageBase64Encoded = + 'gAEAAgWZsAKPnZ6vMobike0KV4I/ucjxTM1cFYhLnVhPWfjfdKoOY1FDVNQO/Z765wTNsdLb47zNGyRTzimR256kfU8y3yQl7xa/OcXC12miM213Z2zhbPpJUnX1USJbifcn1McDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpPYaJZpJBu+GtdWd11V2+vYR71YvIR7LefZHyZWbdoigDAwAFAroSAAADAAkD6AMAAAAAAAAEAwIBAAkD6AMAAAAAAAAA'; + +const signedTransaction = { + lifetimeConstraint: { + blockhash: blockhash('59ArZcTrPbcFbEt7yU1JRrP8bWT6ZGcCMLiDVLLzYZ3m'), + lastValidBlockHeight: 18446744073709551615n, + }, + messageBytes: new Uint8Array([ + 128, 1, 0, 2, 5, 153, 176, 2, 143, 157, 158, 175, 50, 134, 226, 145, 237, + 10, 87, 130, 63, 185, 200, 241, 76, 205, 92, 21, 136, 75, 157, 88, 79, 89, + 248, 223, 116, 170, 14, 99, 81, 67, 84, 212, 14, 253, 158, 250, 231, 4, 205, + 177, 210, 219, 227, 188, 205, 27, 36, 83, 206, 41, 145, 219, 158, 164, 125, + 79, 50, 223, 36, 37, 239, 22, 191, 57, 197, 194, 215, 105, 162, 51, 109, + 119, 103, 108, 225, 108, 250, 73, 82, 117, 245, 81, 34, 91, 137, 247, 39, + 212, 199, 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, + 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0, 6, + 221, 246, 225, 215, 101, 161, 147, 217, 203, 225, 70, 206, 235, 121, 172, + 28, 180, 133, 237, 95, 91, 55, 145, 58, 140, 245, 133, 126, 255, 0, 169, 61, + 134, 137, 102, 146, 65, 187, 225, 173, 117, 103, 117, 213, 93, 190, 189, + 132, 123, 213, 139, 200, 71, 178, 222, 125, 145, 242, 101, 102, 221, 162, + 40, 3, 3, 0, 5, 2, 186, 18, 0, 0, 3, 0, 9, 3, 232, 3, 0, 0, 0, 0, 0, 0, 4, + 3, 2, 1, 0, 9, 3, 232, 3, 0, 0, 0, 0, 0, 0, 0, + ]), + signatures: { + BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP: new Uint8Array([ + 124, 75, 9, 123, 88, 163, 208, 24, 217, 253, 70, 19, 85, 116, 62, 158, + 208, 162, 33, 111, 99, 174, 214, 24, 201, 17, 106, 201, 167, 25, 174, 16, + 35, 174, 165, 207, 71, 228, 251, 61, 78, 246, 21, 160, 130, 65, 29, 73, + 190, 30, 8, 175, 172, 73, 3, 129, 128, 167, 223, 165, 111, 39, 23, 1, + ]), + }, +}; + +const signedTransactionBase64Encoded = + 'AXxLCXtYo9AY2f1GE1V0Pp7QoiFvY67WGMkRasmnGa4QI66lz0fk+z1O9hWggkEdSb4eCK+sSQOBgKffpW8nFwGAAQACBZmwAo+dnq8yhuKR7QpXgj+5yPFMzVwViEudWE9Z+N90qg5jUUNU1A79nvrnBM2x0tvjvM0bJFPOKZHbnqR9TzLfJCXvFr85xcLXaaIzbXdnbOFs+klSdfVRIluJ9yfUxwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKk9holmkkG74a11Z3XVXb69hHvVi8hHst59kfJlZt2iKAMDAAUCuhIAAAMACQPoAwAAAAAAAAQDAgEACQPoAwAAAAAAAAA='; + +const signature = + '3V8bixKujBwAg4DMzJMuxNNUJRsLAw7z8CyHW9Eeio1uZiBEy6NTdFrSBpqVSCaHuuakCytr8w1tJiZbxjhLgNbJ'; + +export const MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN: MockExecutionScenario = { + name: 'Send SPL Token', + scope, + fromAccount, + toAccount, + fromAccountPrivateKeyBytes, + transactionMessage, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + signature, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/types.ts b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/types.ts new file mode 100644 index 00000000..05cf6aed --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/signer/mocks/scenarios/types.ts @@ -0,0 +1,23 @@ +import type { CompilableTransactionMessage } from '@solana/kit'; + +import type { SolanaKeyringAccount } from '../../../../../entities'; +import type { Network } from '../../../../constants/solana'; + +export type MockExecutionScenario = { + name: string; + scope: Network; + fromAccount: SolanaKeyringAccount; + toAccount: SolanaKeyringAccount; + fromAccountPrivateKeyBytes: Uint8Array; + transactionMessage: CompilableTransactionMessage; + transactionMessageBase64Encoded: string; + signedTransaction: any; + signedTransactionBase64Encoded: string; + signature: string; + /* The mock response from the getMultipleAccounts RPC call */ + getMultipleAccountsResponse?: + | { + result: object; + } + | undefined; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/state/IStateManager.ts b/merged-packages/solana-wallet-snap/src/core/services/state/IStateManager.ts new file mode 100644 index 00000000..685121cf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/state/IStateManager.ts @@ -0,0 +1,122 @@ +import type { Serializable } from '../../serialization/types'; + +export type IStateManager> = { + /** + * Gets the whole state object. + * + * ⚠️ WARNING: Use with caution because it transfers the whole state, which might contain a lot of data. + * If you need to retrieve only a specific part of the state, use IStateManager.getKey instead. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.get(); + * // value is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * ``` + */ + get(): Promise; + /** + * Gets the value of passed key in the state object. + * The key is the json path to the value to get. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.getKey('users.1.name'); + * // value is 'Bob' + * + * @returns The value of the key, or undefined if the key does not exist. + */ + getKey( + key: string, + ): Promise; + /** + * Sets the value of passed key in the state object. + * The key is a json path to the value to set. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.set('users.1.name', 'John'); + * // state is now { users: [ { name: 'Alice', age: 20 }, { name: 'John', age: 25 } ] } + * ``` + * @param key - The key to set, which is a json path to the location. + * @param value - The value to set. + */ + setKey(key: string, value: any): Promise; + /** + * Atomically reads the current value at `key`, applies `updater`, and writes the result back. + * Both the read and the write are protected by the same exclusive lock so no concurrent + * operation can interleave between them. + * + * Prefer this over a manual `getKey` + `setKey` sequence whenever the new value depends on + * the current one (e.g. merging objects, appending to arrays). + * + * @example + * ```typescript + * // state is { scores: { alice: 10 } } + * + * await stateManager.setKeyWith('scores', (current) => ({ ...current, bob: 20 })); + * // state is now { scores: { alice: 10, bob: 20 } } + * ``` + * @param key - The json-path key to update. + * @param updater - Receives the current value (or `undefined` when the key is absent) and + * returns the new value to store. + */ + setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise; + /** + * Updates the whole state object. + * + * Typically used for bulk `set`s or `delete`s, because: + * - Atomicity: Using a single `state.update` ensures that all changes are applied atomically. If any part of the operation fails, none of the changes will be applied. This prevents partial updates that could leave the underlying data store in an inconsistent state. + * - Performance: Making multiple individual `state.set` or `state.delete` calls would require multiple round trips to the state storage system, causing potential overheads. + * - State Consistency: Maintains better state consistency by reading the state once, making all modifications in memory and writing the complete updated state back. + * + * ⚠️ WARNING: Use with caution because: + * - it will override the whole state. + * - it transfers the whole state back and forth the data store, which might consume a lot of bandwidth. + * + * For single updates, use instead `setKey` or `deleteKey`. + * + * @param updaterFunction - The function that updates the state. + * @returns The updated state. + */ + update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise; + /** + * Deletes the value of passed key in the state object. + * The key is a json path to the value to delete. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.deleteKey('users.1'); + * // state is now { users: [ { name: 'Alice', age: 20 } ] } + * ``` + */ + deleteKey(key: string): Promise; + /** + * Deletes multiple keys in the state object in a single operation. + * The keys are a json path to the value to delete. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.deleteKeys(['users.0.age', 'users.1.name']); + * // state is now { users: [ { name: 'Alice' }, { age: 25 } ] } + * ``` + */ + deleteKeys(keys: string[]): Promise; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts b/merged-packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts new file mode 100644 index 00000000..a3ea1645 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts @@ -0,0 +1,62 @@ +import { get, set, unset } from 'lodash'; + +import type { Serializable } from '../../serialization/types'; +import type { IStateManager } from './IStateManager'; + +/** + * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. + */ +export class InMemoryState< + TStateValue extends Record, +> implements IStateManager { + #state: TStateValue; + + constructor(initialState: TStateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + const value = get(this.#state, key); + + return value as TResponse | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise { + const oldValue = get(this.#state, key) as TValue | undefined; + const newValue = updater(oldValue); + + set(this.#state, key, newValue); + } + + async update( + callback: (state: TStateValue) => TStateValue, + ): Promise { + this.#state = callback(this.#state); + + return this.#state; + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } + + async deleteKeys(keys: string[]): Promise { + keys.forEach((key) => { + unset(this.#state, key); + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/state/State.test.ts b/merged-packages/solana-wallet-snap/src/core/services/state/State.test.ts new file mode 100644 index 00000000..724758df --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/state/State.test.ts @@ -0,0 +1,436 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import BigNumber from 'bignumber.js'; + +import { EventEmitter } from '../../../infrastructure/event-emitter/EventEmitter'; +import { mockLogger } from '../mocks/logger'; +import { State } from './State'; + +const snap = { + request: jest.fn(), +}; + +(globalThis as any).snap = snap; + +type User = { + name: string; + age: BigNumber | bigint | number | undefined | null; +}; + +type MockStateValue = { + users: User[]; +}; + +const DEFAULT_STATE: MockStateValue = { + users: [ + { + name: 'John', + age: 30, + }, + { + name: 'Jane', + age: 25, + }, + ], +}; + +describe('State', () => { + let state: State; + let eventEmitter: EventEmitter; + + beforeEach(() => { + eventEmitter = new EventEmitter(mockLogger); + + state = new State(eventEmitter, { + encrypted: false, + defaultState: DEFAULT_STATE, + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + snap.request.mockReset(); + }); + + describe('constructor', () => { + it('runs migrateState on onStart/onUpdate/onInstall events', async () => { + const spy = jest.spyOn(state, 'update'); + + await eventEmitter.emitSync('onStart'); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('gets the state', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + expect(stateValue).toStrictEqual(mockUnderlyingState); + }); + + it('gets the default state if the snap state is empty', async () => { + const mockUnderlyingState = {}; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual(DEFAULT_STATE); + }); + + describe('when getting serialized non-JSON values', () => { + it('deserializes undefined values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'undefined', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toEqual({ + users: [ + { + name: 'John', + age: undefined, + }, + ], + }); + }); + + it('deserializes BigNumber values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'BigNumber', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: new BigNumber(30), + }, + ], + }); + }); + + it('deserializes bigint values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'bigint', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: BigInt(30), + }, + ], + }); + }); + }); + }); + + describe('getKey', () => { + it('calls the snap_getState method with the correct parameters', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + await state.getKey('users.1.name'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { key: 'users.1.name', encrypted: false }, + }); + }); + + it('returns undefined if the key does not exist', async () => { + snap.request.mockResolvedValue(null); + + const value = await state.getKey('users.1.name'); + + expect(value).toBeUndefined(); + }); + }); + + describe('setKey', () => { + it('sets the value of a key', async () => { + await state.setKey('users.1.name', 'Bob'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_setState', + params: { + key: 'users.1.name', + value: 'Bob', + encrypted: false, + }, + }); + }); + }); + + describe('setKeyWith', () => { + it('reads the current value, applies the updater, and writes the result', async () => { + snap.request.mockResolvedValueOnce({ alice: 10 }); // getState (read) + + await state.setKeyWith>('scores', (current) => ({ + ...current, + bob: 20, + })); + + expect(snap.request).toHaveBeenNthCalledWith(1, { + method: 'snap_getState', + params: { key: 'scores', encrypted: false }, + }); + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_setState', + params: { + key: 'scores', + value: { alice: 10, bob: 20 }, + encrypted: false, + }, + }); + }); + + it('passes undefined to the updater when the key does not exist', async () => { + snap.request.mockResolvedValueOnce(null); // getState returns null → key absent + + const updater = jest.fn().mockReturnValue({ bob: 20 }); + + await state.setKeyWith('scores', updater); + + expect(updater).toHaveBeenCalledWith(undefined); + }); + }); + + describe('update', () => { + it('updates the state', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: 50, + }, + ], + })); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: 50, + }, + ], + }, + }, + }); + }); + + describe('when updating serialized non-JSON values', () => { + it('serializes undefined values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: undefined, + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'undefined', + }, + }, + ], + }, + }, + }); + }); + + it('serializes BigNumber values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: new BigNumber(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'BigNumber', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes bigint values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: BigInt(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'bigint', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes null values', async () => { + await state.update((currentState) => ({ + users: [...currentState.users, { name: 'Bob', age: null }], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [...DEFAULT_STATE.users, { name: 'Bob', age: null }], + }, + }, + }); + }); + }); + }); + + describe('deleteKey', () => { + it('deletes a key', async () => { + await state.deleteKey('users'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_setState', + params: { + key: 'users', + value: { + __type: 'undefined', + }, + encrypted: false, + }, + }); + }); + + it('deletes a nested key', async () => { + await state.deleteKey('users[0].age'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_setState', + params: { + key: 'users[0].age', + value: { + __type: 'undefined', + }, + encrypted: false, + }, + }); + }); + }); + + describe('deleteKeys', () => { + it('deletes multiple keys', async () => { + await state.deleteKeys(['users.0.age', 'users.1.name']); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: { users: [{ name: 'John' }, { age: 25 }] }, + encrypted: false, + }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/state/State.ts b/merged-packages/solana-wallet-snap/src/core/services/state/State.ts new file mode 100644 index 00000000..2271a5ce --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/state/State.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { Transaction } from '@metamask/keyring-api'; +import type { Address, Signature } from '@solana/kit'; +import type { MutexInterface } from 'async-mutex'; +import { Mutex } from 'async-mutex'; +import { omit, unset } from 'lodash'; + +import type { + AssetEntity, + SolanaKeyringAccount, + Subscription, +} from '../../../entities'; +import type { EventEmitter } from '../../../infrastructure'; +import type { SpotPrices } from '../../clients/price-api/types'; +import { deserialize } from '../../serialization/deserialize'; +import { serialize } from '../../serialization/serialize'; +import type { Serializable } from '../../serialization/types'; +import { safeMerge } from '../../utils/safeMerge'; +import type { IStateManager } from './IStateManager'; + +export type AccountId = string; + +export type UnencryptedStateValue = { + keyringAccounts: Record; + mapInterfaceNameToId: Record; + transactions: Record; + // we need to store the exhaustive list of signatures (including spam) + // to keep track of the transactions per account. The field transactions above only stores non-spam transactions, which break the refreshAccounts cronjob logic. + signatures: Record; + assetEntities: Record; + tokenPrices: SpotPrices; + subscriptions: Record; + webSocketConnections: { + closeWebSocketConnectionsBackgroundEventId: string | null; + }; +}; + +export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { + keyringAccounts: {}, + mapInterfaceNameToId: {}, + transactions: {}, + signatures: {}, + assetEntities: {}, + tokenPrices: {}, + subscriptions: {}, + webSocketConnections: { + closeWebSocketConnectionsBackgroundEventId: null, + }, +}; + +export type StateConfig> = { + encrypted: boolean; + defaultState: TValue; +}; + +/** + * Because we use both snap_manageState and snap_setState, we must protect against them being used at the same time. + * We must also protect against multiple parallel requests to snap_manageState. + * snap_setState, snap_getState etc does not have this limitation and can be accessed safely as long as + * an ongoing manageState operation is not occurring. + */ +class StateLock { + readonly #blobModificationMutex = new Mutex(); + + readonly #regularStateUpdateMutex = new Mutex(); + + #pendingRegularStateUpdates = 0; + + #releaseRegularStateUpdateMutex: MutexInterface.Releaser | null = null; + + async #acquireRegularStateUpdateMutex() { + if (!this.#regularStateUpdateMutex.isLocked()) { + this.#releaseRegularStateUpdateMutex = + await this.#regularStateUpdateMutex.acquire(); + } + } + + async wrapRegularStateOperation( + callback: MutexInterface.Worker, + ): Promise { + // If we are currently doing a full blob update, wait it out. + // Signal that regular state operations are ongoing by acquring the mutex. + // Other regular state operations can skip this, as they are safe to do in parallel. + await Promise.all([ + this.#blobModificationMutex.waitForUnlock(), + this.#acquireRegularStateUpdateMutex(), + ]); + + try { + this.#pendingRegularStateUpdates += 1; + return await callback(); + } finally { + this.#pendingRegularStateUpdates -= 1; + + if ( + this.#pendingRegularStateUpdates === 0 && + this.#releaseRegularStateUpdateMutex + ) { + this.#releaseRegularStateUpdateMutex(); + } + } + } + + async wrapManageStateOperation( + callback: MutexInterface.Worker, + ): Promise { + await this.#regularStateUpdateMutex.waitForUnlock(); + + return await this.#blobModificationMutex.runExclusive(callback); + } +} + +/** + * This class is a layer on top the the `snap_manageState` API that facilitates its usage: + * + * Basic usage: + * - Get and update the sate of the snap + * + * Serialization: + * - It serializes the data before storing it in the snap state because only JSON-assignable data can be stored. + * - It deserializes the data after retrieving it from the snap state. + * - So you don't need to worry about the data format when storing or retrieving data. + * + * Default values: + * - It merges the default state with the underlying snap state to ensure that we always have default values, + * letting us avoid a ton of null checks everywhere. + */ +export class State< + TStateValue extends Record, +> implements IStateManager { + readonly #lock = new StateLock(); + + readonly #config: StateConfig; + + constructor(eventEmitter: EventEmitter, config: StateConfig) { + this.#config = config; + + eventEmitter.on('onStart', this.#migrateState.bind(this)); + eventEmitter.on('onUpdate', this.#migrateState.bind(this)); + eventEmitter.on('onInstall', this.#migrateState.bind(this)); + } + + async #migrateState() { + await this.update((state) => { + return omit(state as any, ['assets']); + }); + } + + async #unsafeGet(): Promise { + const state = await snap.request({ + method: 'snap_getState', + params: { + encrypted: this.#config.encrypted, + }, + }); + + const stateDeserialized = deserialize(state ?? {}) as TStateValue; + + // Merge the default state with the underlying snap state + // to ensure that we always have default values. It lets us avoid a ton of null checks everywhere. + const stateWithDefaults = safeMerge( + this.#config.defaultState, + stateDeserialized, + ); + + return stateWithDefaults; + } + + async get(): Promise { + return this.#lock.wrapRegularStateOperation(async () => this.#unsafeGet()); + } + + async getKey( + key: string, + ): Promise { + return this.#lock.wrapRegularStateOperation(async () => { + const value = await snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.#config.encrypted, + }, + }); + + if (value === null) { + return undefined; + } + + return deserialize(value) as TResponse; + }); + } + + async setKey(key: string, value: Serializable): Promise { + await this.#lock.wrapRegularStateOperation(async () => { + await snap.request({ + method: 'snap_setState', + params: { + key, + value: serialize(value), + encrypted: this.#config.encrypted, + }, + }); + }); + } + + async setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise { + await this.#lock.wrapManageStateOperation(async () => { + const rawValue = await snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.#config.encrypted, + }, + }); + + const oldValue = + rawValue === null ? undefined : (deserialize(rawValue) as TValue); + const newValue = updater(oldValue); + + await snap.request({ + method: 'snap_setState', + params: { + key, + value: serialize(newValue), + encrypted: this.#config.encrypted, + }, + }); + }); + } + + async update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise { + // Because this function modifies the entire state blob, + // we must protect against parallel requests. + return await this.#lock.wrapManageStateOperation(async () => { + const currentState = await this.#unsafeGet(); + + const newState = updaterFunction(currentState); + + // Generally we should try to use snap_getState and snap_setState over this + // as snap_manageState is slower and error-prone due to requiring manual mutex management. + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: serialize(newState), + encrypted: this.#config.encrypted, + }, + }); + + return newState; + }); + } + + async deleteKey(key: string): Promise { + return this.setKey(key, undefined); + } + + async deleteKeys(keys: string[]): Promise { + await this.update((state) => { + keys.forEach((key) => { + unset(state, key); + }); + return state; + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts new file mode 100644 index 00000000..578e2dca --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts @@ -0,0 +1,731 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { TransactionType } from '@metamask/keyring-api'; +import type { Transaction } from '@metamask/keyring-api'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import type { Address } from '@solana/kit'; +import { signature } from '@solana/kit'; + +import type { + AccountNotification, + AccountNotificationHandler, + ConfirmedSubscription, + ProgramNotification, + ProgramNotificationHandler, + Subscription, +} from '../../../entities'; +import { KnownCaip19Id, Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNTS } from '../../test/mocks/solana-keyring-accounts'; +import { trackError } from '../../utils/errors'; +import type { AccountsSynchronizer } from '../accounts'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { AssetsService, TokenHelper } from '../assets'; +import type { ConfigProvider } from '../config'; +import { mockLogger } from '../mocks/logger'; +import type { TransactionsService } from '../transactions'; +import { KeyringAccountMonitor } from './KeyringAccountMonitor'; +import type { SubscriptionService } from './SubscriptionService'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('KeyringAccountMonitor', () => { + let keyringAccountMonitor: KeyringAccountMonitor; + let mockSubscriptionService: SubscriptionService; + let mockAccountService: AccountsService; + let mockAssetsService: AssetsService; + let mockTransactionsService: TransactionsService; + let mockAccountsSynchronizer: AccountsSynchronizer; + let mockTokenHelper: TokenHelper; + let mockConfigProvider: ConfigProvider; + + const account = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + + let accountNotificationHandlers: AccountNotificationHandler[] = []; + let programNotificationHandlers: ProgramNotificationHandler[] = []; + + const createAccountSubscribeSubscription = ( + address: string, + network: Network, + ): ConfirmedSubscription => ({ + id: 'some-subscription-id', + status: 'confirmed', + method: 'accountSubscribe', + network, + params: [address, { commitment: 'confirmed' as const }], + rpcSubscriptionId: 1, + requestId: 'some-request-id', + createdAt: '2024-01-01T00:00:00.000Z', + confirmedAt: '2024-01-02T00:00:00.000Z', + }); + + const createProgramSubscribeSubscription = ( + address: string, + programAddress: Address, + network: Network, + ): ConfirmedSubscription => ({ + id: 'some-subscription-id', + status: 'confirmed', + method: 'programSubscribe', + network, + params: [ + programAddress, + { + commitment: 'confirmed' as const, + encoding: 'jsonParsed', + filters: [ + { + memcmp: { + offset: 32, + bytes: address, + encoding: 'base58', + }, + }, + ], + }, + ], + rpcSubscriptionId: 1, + requestId: 'some-request-id', + createdAt: '2024-01-01T00:00:00.000Z', + confirmedAt: '2024-01-02T00:00:00.000Z', + }); + + beforeEach(() => { + jest.clearAllMocks(); + + accountNotificationHandlers = []; + programNotificationHandlers = []; + + mockSubscriptionService = { + subscribe: jest.fn().mockImplementation(async (request) => { + // Return a simple hash of the request for testing purposes + return JSON.stringify(request) + .split('') + .reduce((acc, char) => acc + char.charCodeAt(0), 0) + .toString(); + }), + unsubscribe: jest.fn(), + getAll: jest.fn().mockResolvedValue([]), + registerNotificationHandler: jest + .fn() + .mockImplementation(async (method, _network, handler) => { + if (method === 'accountSubscribe') { + accountNotificationHandlers.push(handler); + } else if (method === 'programSubscribe') { + programNotificationHandlers.push(handler); + } + }), + registerConnectionRecoveryHandler: jest.fn(), + } as unknown as SubscriptionService; + + mockAccountService = { + getAll: jest.fn(), + findByAddress: jest.fn(), + } as unknown as AccountsService; + + mockAssetsService = { + getTokenAccountsByOwnerMultiple: jest.fn(), + save: jest.fn(), + getAssetsMetadata: jest.fn().mockImplementation((assetType) => ({ + [assetType]: { + symbol: 'USDC', + decimals: 6, + }, + })), + } as unknown as AssetsService; + + mockTransactionsService = { + fetchLatestSignatures: jest.fn(), + fetchBySignature: jest.fn(), + save: jest.fn(), + } as unknown as TransactionsService; + + mockAccountsSynchronizer = { + synchronize: jest.fn(), + } as unknown as AccountsSynchronizer; + + mockTokenHelper = { + uiAmountToAmountForMint: jest.fn(), + amountToUiAmountForMint: jest.fn(), + } as unknown as TokenHelper; + + mockConfigProvider = { + getActiveNetworks: jest + .fn() + .mockResolvedValue([Network.Mainnet, Network.Devnet]), + } as unknown as ConfigProvider; + + keyringAccountMonitor = new KeyringAccountMonitor( + mockSubscriptionService, + mockAccountService, + mockAssetsService, + mockTransactionsService, + mockAccountsSynchronizer, + mockTokenHelper, + mockConfigProvider, + mockLogger, + ); + }); + + describe('constructor', () => { + it('registers handlers for account and program notifications', () => { + expect( + mockSubscriptionService.registerNotificationHandler, + ).toHaveBeenCalledWith( + 'accountSubscribe', + Network.Mainnet, + expect.any(Function), + ); + }); + + it('registers handlers for connection recovery', () => { + expect( + mockSubscriptionService.registerConnectionRecoveryHandler, + ).toHaveBeenCalledWith(Network.Mainnet, expect.any(Function)); + }); + }); + + describe('setMonitoredAccounts', () => { + const account0 = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + const account1 = MOCK_SOLANA_KEYRING_ACCOUNTS[1]; + const account2 = MOCK_SOLANA_KEYRING_ACCOUNTS[2]; + const accounts = [account0, account1, account2]; + + beforeEach(() => { + // Setup 1 active network for simplicity + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet]); + + jest.spyOn(mockAccountService, 'getAll').mockResolvedValue(accounts); + }); + + it('starts monitoring the passed accounts that are not currently monitored', async () => { + // No account currently monitored + jest.spyOn(mockSubscriptionService, 'getAll').mockResolvedValue([]); + + await keyringAccountMonitor.setMonitoredAccounts([ + account0.id, + account1.id, + ]); + + // It should start monitoring account2 and account3 because they are in the requested list and were not currently monitored + expect(mockSubscriptionService.subscribe).toHaveBeenCalledTimes(6); + }); + + it('does not start monitoring the passed accounts that are currently monitored', async () => { + // account0 is currently monitored = we have a subscription for it + const mockSubscriptionAccount0 = createAccountSubscribeSubscription( + account0.address, + Network.Mainnet, + ); + jest + .spyOn(mockSubscriptionService, 'getAll') + .mockResolvedValue([mockSubscriptionAccount0]); + + await keyringAccountMonitor.setMonitoredAccounts([account0.id]); + + // It should not start monitoring account0 because it is already monitored + expect(mockSubscriptionService.subscribe).not.toHaveBeenCalled(); + }); + + it('stops monitoring the accounts that are currently monitored, that are not in the passed list', async () => { + // account0 is currently monitored = we have a subscription for it + const mockSubscriptionAccount0 = createAccountSubscribeSubscription( + account0.address, + Network.Mainnet, + ); + jest + .spyOn(mockSubscriptionService, 'getAll') + .mockResolvedValue([mockSubscriptionAccount0]); + + await keyringAccountMonitor.setMonitoredAccounts([]); + + // It should stop monitoring account0 because it was previously monitored, but is not the requested list + expect(mockSubscriptionService.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does not stop monitoring accounts that are not currently monitored', async () => { + // No account currently monitored + jest.spyOn(mockSubscriptionService, 'getAll').mockResolvedValue([]); + + await keyringAccountMonitor.setMonitoredAccounts([account1.id]); + + // It should not stop monitoring any account + expect(mockSubscriptionService.unsubscribe).not.toHaveBeenCalled(); + }); + + it('does not stop monitoring accounts that are currently monitored and in the requested list', async () => { + // account0 is currently monitored + const mockSubscriptionAccount0 = createAccountSubscribeSubscription( + account0.address, + Network.Mainnet, + ); + jest + .spyOn(mockSubscriptionService, 'getAll') + .mockResolvedValue([mockSubscriptionAccount0]); + + await keyringAccountMonitor.setMonitoredAccounts([account0.id]); + + // It should not stop monitoring account0 because it's in both current and requested lists + expect(mockSubscriptionService.unsubscribe).not.toHaveBeenCalled(); + expect(mockSubscriptionService.subscribe).not.toHaveBeenCalled(); + }); + + it('mixed case', async () => { + // account0 and account1 are currently monitored = we have subscriptions for them + const mockSubscriptionAccount0 = createAccountSubscribeSubscription( + account0.address, + Network.Mainnet, + ); + const mockSubscriptionAccount1 = createProgramSubscribeSubscription( + account1.address, + TOKEN_PROGRAM_ADDRESS, + Network.Mainnet, + ); + jest + .spyOn(mockSubscriptionService, 'getAll') + .mockResolvedValue([ + mockSubscriptionAccount0, + mockSubscriptionAccount1, + ]); + + await keyringAccountMonitor.setMonitoredAccounts([ + account1.id, + account2.id, + ]); + + // It should stop monitoring account0 because it was previously monitored, but is not the requested list + expect(mockSubscriptionService.unsubscribe).toHaveBeenCalledTimes(1); + + // It should start monitoring account2 because it is in the requested list and is not currently monitored + expect(mockSubscriptionService.subscribe).toHaveBeenCalledTimes(3); + }); + }); + + describe('when receiving a notification', () => { + const mockSignature = signature( + '4Pjp2FVBTA2FQCbF3UurnHES3hz2Zx5pTJeVEVhvcCCS7m5CytKqLvcQUGiUMPSBVW5V3dL5N8jwXpT8eV52Sw7b', + ); + + const mockCausingTransaction = { + id: mockSignature.toString(), + } as unknown as Transaction; + + beforeEach(() => { + // Setup 1 active network for simplicity + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet]); + + jest.spyOn(mockAccountService, 'getAll').mockResolvedValue([account]); + + jest + .spyOn(mockTransactionsService, 'fetchLatestSignatures') + .mockResolvedValue([mockSignature]); + + jest + .spyOn(mockTransactionsService, 'fetchBySignature') + .mockResolvedValue(mockCausingTransaction); + + jest + .spyOn(mockAccountService, 'findByAddress') + .mockResolvedValue(account); + }); + + describe('when the native asset changed', () => { + const mockNotification: AccountNotification = { + jsonrpc: '2.0', + method: 'accountNotification', + params: { + subscription: 1, + result: { + context: { + slot: 1, + }, + value: { + data: {}, + executable: false, + lamports: 1000000000, // 1 SOL + owner: '11111111111111111111111111111111', + rentEpoch: null, + }, + }, + }, + }; + + const mockSubscription = { + method: 'accountSubscribe', + network: Network.Mainnet, + params: [account.address, { commitment: 'confirmed' as const }], + } as unknown as Subscription; + + it('saves the new balance of the native asset', async () => { + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + // Send the notification by manually calling the handler + const handler = accountNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).toHaveBeenCalledWith({ + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: account.id, + network: Network.Mainnet, + address: account.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + + it('fetches and saves the transaction that caused the native asset balance to change', async () => { + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + // Send the notification by manually calling the handler + const handler = accountNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + + it('skips spam transactions', async () => { + const mockSpamTransaction = { + ...mockCausingTransaction, + type: TransactionType.Receive, + to: [ + { + address: account.address, + asset: { + fungible: true, + type: KnownCaip19Id.SolMainnet, + amount: '0.00000001', // Very small amount so that it's caught by the spam detector + }, + }, + ], + } as unknown as Transaction; + + jest + .spyOn(mockTransactionsService, 'fetchBySignature') + .mockResolvedValue(mockSpamTransaction); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + // Send the notification by manually calling the handler + const handler = accountNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockTransactionsService.save).not.toHaveBeenCalled(); + }); + + it('throws an error when lamports is missing', async () => { + const mockNotificationWithMissingLamports: AccountNotification = { + jsonrpc: '2.0', + method: 'accountNotification', + params: { + subscription: 1, + result: { + context: { + slot: 1, + }, + value: { + data: {}, + executable: false, + lamports: undefined as unknown as number, // Lamports is missing + owner: '11111111111111111111111111111111', + rentEpoch: null, + }, + }, + }, + }; + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = accountNotificationHandlers[0]!; + await expect( + handler(mockNotificationWithMissingLamports, mockSubscription), + ).rejects.toThrow('Expected a number, but received: undefined'); + }); + }); + + describe('when a token asset changed', () => { + const mockNotification: ProgramNotification = { + jsonrpc: '2.0', + method: 'programNotification', + params: { + subscription: 1, + result: { + context: { + slot: 1, + }, + value: { + account: { + data: { + parsed: { + type: 'account', + info: { + isNative: false, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + owner: account.address, + state: 'initialized', + tokenAmount: { + amount: '123456789', + decimals: 6, + uiAmount: 123.456789, + uiAmountString: '123.456789', + }, + }, + }, + program: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + space: 165, + }, + executable: true, + lamports: 1000000000, + owner: account.address, + rentEpoch: 1, + }, + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + }, + }, + }, + }; + + const mockSubscription = { + method: 'programSubscribe', + network: Network.Mainnet, + params: [TOKEN_PROGRAM_ADDRESS, { commitment: 'confirmed' as const }], + } as unknown as Subscription; + + beforeEach(() => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('123.456789'); + }); + + it('tracks ui amount fallback errors and keeps the raw value', async () => { + const error = new Error('Conversion failed'); + + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockRejectedValue(error); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = programNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(trackError).toHaveBeenCalledWith(error); + }); + + it('saves the new balance of the token asset and the transaction that caused it', async () => { + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = programNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).toHaveBeenCalledWith({ + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: account.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + symbol: 'USDC', + decimals: 6, + rawAmount: '123456789', + uiAmount: '123.456789', + }); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + + it('fetches and saves the transaction that caused the token asset to change', async () => { + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = programNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + + it('throws an error when mint address is missing', async () => { + const mockNotificationWithMissingMint: ProgramNotification = { + jsonrpc: '2.0', + method: 'programNotification', + params: { + subscription: 1, + result: { + context: { + slot: 1, + }, + value: { + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + account: { + data: { + parsed: { + info: { + isNative: false, + mint: undefined as unknown as string, // Mint is missing + owner: account.address, + state: 'initialized', + tokenAmount: { + amount: '20011079', + decimals: 6, + uiAmount: 20.011079, + uiAmountString: '20.011079', + }, + }, + type: 'account', + }, + program: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + space: 165, + }, + executable: true, + lamports: 1000000000, + owner: account.address, + rentEpoch: 1, + }, + }, + }, + }, + }; + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + const handler = programNotificationHandlers[0]!; + + await expect( + handler(mockNotificationWithMissingMint, mockSubscription), + ).rejects.toThrow('Expected a string, but received: undefined'); + expect(mockAssetsService.save).not.toHaveBeenCalled(); + }); + + it('throws an error when uiAmountString is missing', async () => { + const mockNotificationWithMissingUiAmountString: ProgramNotification = { + jsonrpc: '2.0', + method: 'programNotification', + params: { + subscription: 1, + result: { + context: { + slot: 1, + }, + value: { + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + account: { + data: { + parsed: { + info: { + isNative: false, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + owner: account.address, + state: 'initialized', + tokenAmount: { + amount: '20011079', + decimals: 6, + uiAmount: 20.011079, + uiAmountString: undefined as unknown as string, // uiAmountString is missing + }, + }, + type: 'account', + }, + program: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + space: 165, + }, + executable: true, + lamports: 1000000000, + owner: account.address, + rentEpoch: 1, + }, + }, + }, + }, + }; + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + const handler = programNotificationHandlers[0]!; + + await expect( + handler(mockNotificationWithMissingUiAmountString, mockSubscription), + ).rejects.toThrow('Expected a string, but received: undefined'); + expect(mockAssetsService.save).not.toHaveBeenCalled(); + }); + + describe('when #saveCausingTransaction encounters errors', () => { + it('throws an error when no signatures are found', async () => { + // No signatures found for the token account + jest + .spyOn(mockTransactionsService, 'fetchLatestSignatures') + .mockResolvedValue([]); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + const handler = programNotificationHandlers[0]!; + + await expect( + handler(mockNotification, mockSubscription), + ).rejects.toThrow('No signature found'); + expect(mockTransactionsService.save).not.toHaveBeenCalled(); + }); + + it('throws an error when transaction is not found', async () => { + // No transaction found for the token account + jest + .spyOn(mockTransactionsService, 'fetchBySignature') + .mockResolvedValue(null); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + const handler = programNotificationHandlers[0]!; + + await expect( + handler(mockNotification, mockSubscription), + ).rejects.toThrow('No transaction found'); + expect(mockTransactionsService.save).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('connection recovery', () => { + const account0 = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + const account1 = MOCK_SOLANA_KEYRING_ACCOUNTS[1]; + const accounts = [account0, account1]; + + beforeEach(() => { + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet]); + + // Setup 2 accounts + jest.spyOn(mockAccountService, 'getAll').mockResolvedValue(accounts); + + // These accounts are currently monitored + jest + .spyOn(mockSubscriptionService, 'getAll') + .mockResolvedValue([ + createAccountSubscribeSubscription(account0.address, Network.Mainnet), + createAccountSubscribeSubscription(account1.address, Network.Mainnet), + ]); + }); + + it('syncs all monitored accounts', async () => { + const syncSpy = jest.spyOn(mockAccountsSynchronizer, 'synchronize'); + + // Simulate connection recovery + const recoveryHandler = ( + mockSubscriptionService.registerConnectionRecoveryHandler as jest.Mock + ).mock.calls[0][1]; + await recoveryHandler(Network.Mainnet); + + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith(accounts); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts new file mode 100644 index 00000000..38eb4c32 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts @@ -0,0 +1,533 @@ +import { assert, number, string } from '@metamask/superstruct'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import type { Base58EncodedBytes } from '@solana/kit'; +import { address as asAddress, lamports } from '@solana/kit'; +import { get, uniq } from 'lodash'; + +import type { SubscriptionService } from '.'; +import type { + AccountNotification, + ProgramNotification, + SolanaKeyringAccount, + Subscription, +} from '../../../entities'; +import type { Network } from '../../constants/solana'; +import { SolanaCaip19Tokens } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import { fromTokenUnits } from '../../utils/fromTokenUnit'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { AccountsSynchronizer } from '../accounts'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { AssetsService, TokenHelper } from '../assets'; +import type { ConfigProvider } from '../config'; +import { SUPPORTED_NETWORKS } from '../config/ConfigProvider'; +import type { TransactionsService } from '../transactions'; +import { isSpam } from '../transactions/utils/isSpam'; + +/** + * Business logic for monitoring keyring accounts via WebSockets: + * + * - It gets updates when the balance of the native asset (SOL) changes by subscribing to the RPC account. + * - It gets updates when the balance of token assets change by subscribing to each RPC token account. + * + * On each update: + * - It saves the new balance. Under the hood, AssetsService also notifies the extension. + * - It fetches the transaction that caused the native asset or token asset to change and saves it. Under the hood, TransactionsService also notifies the extension. + */ +export class KeyringAccountMonitor { + readonly #subscriptionService: SubscriptionService; + + readonly #accountService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #transactionsService: TransactionsService; + + readonly #accountsSynchronizer: AccountsSynchronizer; + + readonly #tokenHelper: TokenHelper; + + readonly #configProvider: ConfigProvider; + + readonly #logger: ILogger; + + readonly #tokenProgramsAddresses = [ + TOKEN_PROGRAM_ADDRESS, + TOKEN_2022_PROGRAM_ADDRESS, + ]; + + constructor( + subscriptionService: SubscriptionService, + accountService: AccountsService, + assetsService: AssetsService, + transactionsService: TransactionsService, + accountsSynchronizer: AccountsSynchronizer, + tokenHelper: TokenHelper, + configProvider: ConfigProvider, + logger: ILogger, + ) { + this.#subscriptionService = subscriptionService; + this.#accountService = accountService; + this.#assetsService = assetsService; + this.#transactionsService = transactionsService; + this.#accountsSynchronizer = accountsSynchronizer; + this.#tokenHelper = tokenHelper; + this.#configProvider = configProvider; + this.#logger = createPrefixedLogger(logger, '[🗝️ KeyringAccountMonitor]'); + + this.#bindHandlers(); + } + + #bindHandlers(): void { + this.#logger.info('Binding handlers'); + + // Register callbacks that will handle account and program notifications. + SUPPORTED_NETWORKS.forEach((network) => { + this.#subscriptionService.registerNotificationHandler( + 'accountSubscribe', + network, + this.#handleAccountNotification.bind(this), + ); + this.#subscriptionService.registerNotificationHandler( + 'programSubscribe', + network, + this.#handleProgramNotification.bind(this), + ); + }); + + // Register the connection recovery callback that will handle missed messages. + SUPPORTED_NETWORKS.forEach((network) => { + this.#subscriptionService.registerConnectionRecoveryHandler( + network, + this.#handleConnectionRecovery.bind(this), + ); + }); + } + + /** + * Sets the monitored accounts. It will: + * - stop monitoring accounts currently monitored that are not in the list. + * - and start monitoring accounts that are in the list that are not currently monitored. + * + * @param accountIds - The ids of the accounts to set as monitored. + */ + async setMonitoredAccounts(accountIds: string[]): Promise { + this.#logger.info('Setting monitored accounts', accountIds); + + const [allAccounts, allSubscriptions, activeNetworks] = await Promise.all([ + this.#accountService.getAll(), + this.#subscriptionService.getAll(), + this.#configProvider.getActiveNetworks(), + ]); + + const currentlyMonitoredAccounts = allAccounts.filter((account) => + allSubscriptions.some( + (subscription) => + account.address === get(subscription, 'params[0]') || + account.address === + get(subscription, 'params[1].filters[0].memcmp.bytes'), + ), + ); + + // Stop monitoring the currently monitored accounts... + const accountsToStopMonitoring = currentlyMonitoredAccounts + // ...that are not in the passed list + .filter((account) => !accountIds.includes(account.id)); + + // Start monitoring accounts... + const accountsToStartMonitoring = allAccounts + // ...from the passed list + .filter((account) => accountIds.includes(account.id)) + // ...that are not currently monitored + .filter((account) => !currentlyMonitoredAccounts.includes(account)); + + await Promise.allSettled([ + this.#stopMonitorKeyringAccounts( + accountsToStopMonitoring, + allSubscriptions, + ), + this.#startMonitorKeyringAccounts( + accountsToStartMonitoring, + activeNetworks, + ), + ]); + } + + /** + * Batch monitors the native and token assets for a single account across all active networks. + * + * @param accounts - The accounts to monitor the assets for. + * @param networks - The networks to monitor the assets for. + */ + async #startMonitorKeyringAccounts( + accounts: SolanaKeyringAccount[], + networks: Network[], + ): Promise { + try { + this.#logger.log('Monitoring keyring accounts', accounts); + + // Perform a full sync of the accounts + const synchronizePromise = + this.#accountsSynchronizer.synchronize(accounts); + + const promises = accounts.flatMap((account) => { + const shouldMonitorOnNetwork = (network: Network) => + account.scopes.includes(network); + + // Monitor native assets + const nativeAssetsPromises = networks + .filter(shouldMonitorOnNetwork) + .map(async (network) => + this.#monitorAccountNativeAsset(account, network), + ); + + // Monitor token assets + const tokenProgramPromises = networks + .filter(shouldMonitorOnNetwork) + .map(async (network) => { + await Promise.all( + this.#tokenProgramsAddresses.map(async (tokenProgramAddress) => + this.#monitorProgramByOwner( + account, + tokenProgramAddress, + network, + ), + ), + ); + }); + + return [...nativeAssetsPromises, ...tokenProgramPromises]; + }); + + await Promise.allSettled([...promises, synchronizePromise]); + } catch (error) { + this.#logger.error('Error monitoring keyring account', error); + throw error; + } + } + + /** + * Batch stops monitoring the passed accounts. + * + * @param accounts - The accounts to stop monitoring. + * @param subscriptions - The subscriptions to stop monitoring. + */ + async #stopMonitorKeyringAccounts( + accounts: SolanaKeyringAccount[], + subscriptions: Subscription[], + ): Promise { + this.#logger.log('Stopping to monitor accounts', accounts); + + const addresses = accounts.map((account) => account.address); + + const subscriptionsToUnsubscribe = subscriptions + // Only keep the "accountSubscribe" and "programSubscribe" subscriptions + .filter( + (subscription) => + KeyringAccountMonitor.#isAccountSubscribeSubscription(subscription) || + KeyringAccountMonitor.#isProgramSubscribeSubscription(subscription), + ) + // Only keep the subscriptions for the passed accounts + .filter((subscription) => + addresses.includes(KeyringAccountMonitor.#extractAddress(subscription)), + ); + + // Unsubscribe from them all + await Promise.allSettled( + subscriptionsToUnsubscribe.map(async (subscription) => + this.#subscriptionService.unsubscribe(subscription.id), + ), + ); + } + + /** + * Monitors the native asset (SOL) for the given account in the given network. + * + * @param account - The account to monitor the native asset for. + * @param network - The network to monitor the native asset for. + */ + async #monitorAccountNativeAsset( + account: SolanaKeyringAccount, + network: Network, + ): Promise { + this.#logger.log('Monitoring native asset balance', { + account, + network, + }); + + const { address } = account; + + await this.#subscriptionService.subscribe({ + method: 'accountSubscribe', + network, + params: [address, { commitment: 'confirmed', encoding: 'jsonParsed' }], + }); + } + + async #monitorProgramByOwner( + account: SolanaKeyringAccount, + tokenProgramAddress: string, + network: Network, + ): Promise { + this.#logger.log('Monitoring token program by owner', { + account, + tokenProgramAddress, + network, + }); + + const { address } = account; + + await this.#subscriptionService.subscribe({ + method: 'programSubscribe', + network, + params: [ + tokenProgramAddress, + { + commitment: 'confirmed', + encoding: 'jsonParsed', + filters: [ + { + memcmp: { + offset: 32, // Offset of 'owner' in token account layout + bytes: address as Base58EncodedBytes, + encoding: 'base58', + }, + }, + ], + }, + ], + }); + } + + async #handleAccountNotification( + notification: AccountNotification, + subscription: Subscription, + ): Promise { + this.#logger.info('Account notification received', { + notification, + subscription, + }); + const { network } = subscription; + + const address = get(subscription, 'params[0]'); + assert(address, string()); + + // This notification could be for any RPC account. + // Here, we only handle "actual" accounts, not token accounts. + const keyringAccount = await this.#accountService.findByAddress(address); + if (!keyringAccount) { + throw new Error(`No keyring account found for address: ${address}`); + } + + // Handle the notification with clean data + const { lamports: accountLamports } = notification.params.result.value; + assert(accountLamports, number()); + + const decimals = 9; + + await Promise.all([ + this.#assetsService.save({ + assetType: `${network}/${SolanaCaip19Tokens.SOL}`, + keyringAccountId: keyringAccount.id, + network, + address, + symbol: 'SOL', + decimals, + rawAmount: accountLamports.toString(), + uiAmount: fromTokenUnits(accountLamports, decimals), + }), + this.#saveCausingTransaction(keyringAccount, network, address), + ]); + } + + async #handleProgramNotification( + notification: ProgramNotification, + subscription: Subscription, + ): Promise { + this.#logger.info('Handling program notification', { + notification, + subscription, + }); + + const { network } = subscription; + + const programAddress = get(subscription, 'params[0]'); + assert(programAddress, string()); + + if ( + programAddress !== TOKEN_PROGRAM_ADDRESS && + programAddress !== TOKEN_2022_PROGRAM_ADDRESS + ) { + throw new Error(`Program not supported: ${programAddress}`); + } + + const { owner } = notification.params.result.value.account.data.parsed.info; + assert(owner, string()); + + const { mint } = notification.params.result.value.account.data.parsed.info; + assert(mint, string()); + + const { amount, decimals, uiAmountString } = + notification.params.result.value.account.data.parsed.info.tokenAmount; + assert(amount, string()); + assert(decimals, number()); + assert(uiAmountString, string()); + + const { pubkey } = notification.params.result.value; + assert(pubkey, string()); + + const assetType = tokenAddressToCaip19(network, mint); + + const keyringAccount = await this.#accountService.findByAddress(owner); + if (!keyringAccount) { + throw new Error(`No keyring account found with address: ${owner}`); + } + + /** + * WARNING: This is to compensate for the fact that the notification returned by Infura's programSubscribe + * includes a uiAmount/uiAmountString that does not take into account the mint's multiplier (if any). + * In theory, it should; because the regular Solana RPC (wss://api.mainnet-beta.solana.com) does. + * + * So this needs to be removed once Infura fixes their programSubscribe notification. + */ + const uiAmount = await this.#tokenHelper + .amountToUiAmountForMint(mint, network, lamports(BigInt(amount))) + .catch(async (error) => { + await trackError(error); + this.#logger.error('Error converting amount to uiAmount', error); + return uiAmountString; + }); + + const metadata = (await this.#assetsService.getAssetsMetadata([assetType]))[ + assetType + ]; + + await Promise.all([ + // Update the balance of the token asset + this.#assetsService.save({ + assetType, + keyringAccountId: keyringAccount.id, + network, + mint, + pubkey, + symbol: metadata?.symbol ?? 'UNKNOWN', + decimals, + rawAmount: amount, + uiAmount, + }), + // Fetch and save the transaction that caused the token asset change. + this.#saveCausingTransaction(keyringAccount, network, pubkey), + ]); + } + + /** + * Fetch the transaction that caused the RPC account (native asset or token asset) to change and save it. + * This is to cover the case where the balance changed due to a "receive" (transfer from another account outside of the extension). + * + * @param account - The keyring account that the RPC account changed for. + * @param network - The network of the RPC account. + * @param address - The address of the RPC account. + */ + async #saveCausingTransaction( + account: SolanaKeyringAccount, + network: Network, + address: string, + ): Promise { + const signature = ( + await this.#transactionsService.fetchLatestSignatures( + network, + asAddress(address), + { + limit: 1, + }, + ) + )?.[0]; + + if (!signature) { + throw new Error('No signature found'); + } + + const transaction = await this.#transactionsService.fetchBySignature( + signature, + account, + network, + ); + + if (!transaction) { + throw new Error('No transaction found'); + } + + // Ignore spam transactions + if (isSpam(transaction, account)) { + this.#logger.info(`Transaction ${signature} is spam. Skipping.`); + return; + } + + // Note that the TransactionService will avoid saving duplicates in the state. + await this.#transactionsService.save(transaction); + } + + /** + * Recover from potential missed messages (while connection was down) + * by syncing all monitored accounts. + * + * @param network - The network to handle the connection recovery for. + */ + async #handleConnectionRecovery(network: Network): Promise { + this.#logger.info('Handling connection recovery', { network }); + + const [allAccounts, allSubscriptions] = await Promise.all([ + this.#accountService.getAll(), + this.#subscriptionService.getAll(), + ]); + + // Find all 'accountSubscribe' and 'programSubscribe' subscriptions + const relevantSubscriptions = allSubscriptions.filter( + (subscription) => + KeyringAccountMonitor.#isAccountSubscribeSubscription(subscription) || + KeyringAccountMonitor.#isProgramSubscribeSubscription(subscription), + ); + + // Map the addresses + const monitoredAccountsAddresses = uniq( + relevantSubscriptions.map((subscription) => + KeyringAccountMonitor.#extractAddress(subscription), + ), + ); + + // Find the matching accounts + const monitoredAccounts = allAccounts.filter((account) => + monitoredAccountsAddresses.includes(account.address), + ); + + if (!monitoredAccounts.length) { + this.#logger.info( + 'No monitored accounts found. Skipping connection recovery.', + ); + return; + } + + await this.#accountsSynchronizer.synchronize(monitoredAccounts); + } + + static #isAccountSubscribeSubscription(subscription: Subscription): boolean { + return subscription.method === 'accountSubscribe'; + } + + static #isProgramSubscribeSubscription(subscription: Subscription): boolean { + return subscription.method === 'programSubscribe'; + } + + static #extractAddress(subscription: Subscription): string { + if (this.#isAccountSubscribeSubscription(subscription)) { + return get(subscription, 'params[0]') as string; + } + if (this.#isProgramSubscribeSubscription(subscription)) { + return get(subscription, 'params[1].filters[0].memcmp.bytes') as string; + } + throw new Error('Invalid subscription'); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/MonitoredAccountsInitializer.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/MonitoredAccountsInitializer.ts new file mode 100644 index 00000000..d9de8b34 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/MonitoredAccountsInitializer.ts @@ -0,0 +1,50 @@ +import type { EventEmitter } from '../../../infrastructure/event-emitter'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { KeyringAccountMonitor } from './KeyringAccountMonitor'; + +/** + * Initializes the monitored accounts by monitoring the selected accounts when the snap becomes active. + * This service is autonomous (doesn't need to be called by anyone). It listens to the 'onActive' event and handles it. + */ +export class MonitoredAccountsInitializer { + readonly #accountsService: AccountsService; + + readonly #keyringAccountMonitor: KeyringAccountMonitor; + + readonly #eventEmitter: EventEmitter; + + readonly #logger: ILogger; + + constructor( + accountsService: AccountsService, + keyringAccountMonitor: KeyringAccountMonitor, + eventEmitter: EventEmitter, + logger: ILogger, + ) { + this.#accountsService = accountsService; + this.#keyringAccountMonitor = keyringAccountMonitor; + this.#eventEmitter = eventEmitter; + this.#logger = createPrefixedLogger( + logger, + '[🥾 MonitoredAccountsInitializer]', + ); + + this.#bindHandlers(); + } + + #bindHandlers(): void { + this.#eventEmitter.on('onActive', this.#monitorSelectedAccounts.bind(this)); + } + + async #monitorSelectedAccounts(): Promise { + this.#logger.info('Starting to monitor the selected accounts'); + + const selectedAccounts = await this.#accountsService.getAllSelected(); + + await this.#keyringAccountMonitor.setMonitoredAccounts( + selectedAccounts.map((account) => account.id), + ); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.test.ts new file mode 100644 index 00000000..0e7c3abf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.test.ts @@ -0,0 +1,327 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { Transaction } from '@metamask/keyring-api'; + +import type { + ConnectionRecoveryHandler, + SignatureNotification, + SignatureNotificationHandler, + Subscription, +} from '../../../entities'; +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNTS } from '../../test/mocks/solana-keyring-accounts'; +import { trackError } from '../../utils/errors'; +import type { AccountsService } from '../accounts'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { ConfigProvider } from '../config'; +import type { SolanaConnection } from '../connection'; +import { mockLogger } from '../mocks/logger'; +import type { TransactionsService } from '../transactions'; +import { SignatureMonitor } from './SignatureMonitor'; +import type { SubscriptionService } from './SubscriptionService'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('SignatureMonitor', () => { + let signatureMonitor: SignatureMonitor; + let mockSubscriptionService: SubscriptionService; + let mockAccountService: AccountsService; + let mockTransactionsService: TransactionsService; + let mockAnalyticsService: AnalyticsService; + let mockConnection: SolanaConnection; + let mockConfigProvider: ConfigProvider; + + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; + + const signature = + '4s3KAUEZ9N5uwKurHNApnCSXVd6UxPD4VuHFtjcdT4WyXk5g5ZuscqqyJCjgssm81rL2BbjhJULHwBoe5jbNx5yS'; + const accountId = mockAccount.id; + const commitment = 'confirmed' as const; + const network = Network.Mainnet; + const origin = 'test'; + + const mockTransaction = { + id: signature, + } as Transaction; + + let notificationHandlers: SignatureNotificationHandler[] = []; + let connectionRecoveryHandlers: ConnectionRecoveryHandler[] = []; + + beforeEach(() => { + notificationHandlers = []; + connectionRecoveryHandlers = []; + + mockSubscriptionService = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + registerNotificationHandler: jest + .fn() + .mockImplementation(async (_method, _network, handler) => { + // This will let us capture and manually call the handler + notificationHandlers.push(handler); + }), + registerConnectionRecoveryHandler: jest + .fn() + .mockImplementation(async (_network, handler) => { + // This will let us capture and manually call the handler + connectionRecoveryHandlers.push(handler); + }), + } as unknown as SubscriptionService; + + mockAccountService = { + findById: jest.fn().mockResolvedValue(mockAccount), + } as unknown as AccountsService; + + mockTransactionsService = { + fetchBySignature: jest.fn().mockResolvedValue(mockTransaction), + save: jest.fn(), + } as unknown as TransactionsService; + + mockAnalyticsService = { + trackEventTransactionFinalized: jest.fn(), + trackEventTransactionSubmitted: jest.fn(), + } as unknown as AnalyticsService; + + mockConnection = { + getRpc: jest.fn().mockImplementation(() => ({ + getSignatureStatuses: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockReturnValue({ + value: [{ confirmationStatus: 'confirmed' }], + }), + })), + })), + } as unknown as SolanaConnection; + + mockConfigProvider = { + getActiveNetworks: jest.fn().mockReturnValue([Network.Mainnet]), + } as unknown as ConfigProvider; + + signatureMonitor = new SignatureMonitor( + mockSubscriptionService, + mockAccountService, + mockTransactionsService, + mockAnalyticsService, + mockConnection, + mockConfigProvider, + mockLogger, + ); + }); + + describe('monitor', () => { + it('subscribes to method signatureSubscribe', async () => { + await signatureMonitor.monitor( + signature, + accountId, + commitment, + network, + origin, + ); + + expect(mockSubscriptionService.subscribe).toHaveBeenCalledWith({ + method: 'signatureSubscribe', + network: Network.Mainnet, + params: [ + signature, + { + commitment: 'confirmed', + enableReceivedNotification: false, + }, + ], + expiryMilliseconds: 600000, + metadata: { + accountId, + origin, + }, + }); + }); + + it('registers a connection recovery handler', async () => { + await signatureMonitor.monitor( + signature, + accountId, + commitment, + network, + origin, + ); + + expect( + mockSubscriptionService.registerConnectionRecoveryHandler, + ).toHaveBeenCalledWith(network, expect.any(Function)); + }); + }); + + describe('#handleSignatureNotification', () => { + it('when the tx is processed, it saves the transaction and unsubscribes', async () => { + const mockNotification = {} as unknown as SignatureNotification; + const mockSubscription = { + id: 'subscription-id-123', + method: 'signatureSubscribe', + network: Network.Mainnet, + params: [ + signature, + { commitment: 'processed', enableReceivedNotification: false }, + ], + metadata: { + accountId, + origin, + }, + } as unknown as Subscription; + + await signatureMonitor.monitor( + signature, + accountId, + 'processed', + network, + origin, + ); + + // Simulate notification received + const handler = notificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockTransaction, + ); + + expect( + mockAnalyticsService.trackEventTransactionSubmitted, + ).not.toHaveBeenCalled(); + + expect(mockSubscriptionService.unsubscribe).toHaveBeenCalledWith( + mockSubscription.id, + ); + }); + + it('when the tx is confirmed, it saves the transaction, tracks an event in analytics, and unsubscribes', async () => { + const mockNotification = {} as unknown as SignatureNotification; + const mockSubscription = { + id: 'subscription-id-123', + method: 'signatureSubscribe', + network: Network.Mainnet, + params: [signature, { commitment, enableReceivedNotification: false }], + metadata: { + accountId, + origin, + }, + } as unknown as Subscription; + + await signatureMonitor.monitor( + signature, + accountId, + 'confirmed', + network, + origin, + ); + + // Simulate notification received + const handler = notificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockTransaction, + ); + + expect( + mockAnalyticsService.trackEventTransactionFinalized, + ).toHaveBeenCalledWith(mockAccount, mockTransaction, { + origin, + scope: network, + }); + + expect(mockSubscriptionService.unsubscribe).toHaveBeenCalledWith( + mockSubscription.id, + ); + }); + + it('tracks notification handling errors and still unsubscribes', async () => { + const error = new Error('Save failed'); + const mockNotification = {} as unknown as SignatureNotification; + const mockSubscription = { + id: 'subscription-id-123', + method: 'signatureSubscribe', + network: Network.Mainnet, + params: [signature, { commitment, enableReceivedNotification: false }], + metadata: { + accountId, + origin, + }, + } as unknown as Subscription; + + jest.spyOn(mockTransactionsService, 'save').mockRejectedValue(error); + + await signatureMonitor.monitor( + signature, + accountId, + 'confirmed', + network, + origin, + ); + + const handler = notificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(trackError).toHaveBeenCalledWith(error); + expect(mockSubscriptionService.unsubscribe).toHaveBeenCalledWith( + mockSubscription.id, + ); + }); + }); + + describe('#handleConnectionRecovery', () => { + it('fetches, saves the transaction and tracks an event in analytics when connection was dropped and recovered, if the tx has reached the desired commitment', async () => { + await signatureMonitor.monitor( + signature, + accountId, + commitment, + network, + origin, + ); + + (mockTransactionsService.fetchBySignature as jest.Mock).mockClear(); + (mockTransactionsService.save as jest.Mock).mockClear(); + + // Simulate connection recovery + const handler = connectionRecoveryHandlers[0]!; + await handler(network); + + expect(mockTransactionsService.fetchBySignature).toHaveBeenCalled(); + expect(mockTransactionsService.save).toHaveBeenCalled(); + expect( + mockAnalyticsService.trackEventTransactionFinalized, + ).toHaveBeenCalled(); + }); + }); + + it('does nothing if the tx has not reached the desired commitment', async () => { + // The transaction is only processed, not confirmed + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignatureStatuses: jest.fn().mockReturnValue({ + send: jest.fn().mockReturnValue({ + value: [{ confirmationStatus: 'processed' }], + }), + }), + } as any); + + await signatureMonitor.monitor( + signature, + accountId, + 'confirmed', + network, + origin, + ); + + (mockTransactionsService.fetchBySignature as jest.Mock).mockClear(); + (mockTransactionsService.save as jest.Mock).mockClear(); + + // Simulate connection recovery + const handler = connectionRecoveryHandlers[0]!; + await handler(network); + + expect(mockTransactionsService.fetchBySignature).not.toHaveBeenCalled(); + expect(mockTransactionsService.save).not.toHaveBeenCalled(); + expect( + mockAnalyticsService.trackEventTransactionFinalized, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.ts new file mode 100644 index 00000000..d0f6df3a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SignatureMonitor.ts @@ -0,0 +1,339 @@ +import { assert, string } from '@metamask/superstruct'; +import { Duration } from '@metamask/utils'; +import { signature as asSignature } from '@solana/kit'; +import { get } from 'lodash'; + +import { CommitmentStruct } from '../../../entities'; +import type { + Commitment, + SignatureNotification, + Subscription, + SubscriptionRequest, +} from '../../../entities'; +import type { Network } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { ConfigProvider } from '../config'; +import { SUPPORTED_NETWORKS } from '../config/ConfigProvider'; +import type { SolanaConnection } from '../connection'; +import type { TransactionsService } from '../transactions'; +import type { SubscriptionService } from './SubscriptionService'; + +export class SignatureMonitor { + readonly #subscriptionService: SubscriptionService; + + readonly #accountService: AccountsService; + + readonly #transactionsService: TransactionsService; + + readonly #analyticsService: AnalyticsService; + + readonly #connection: SolanaConnection; + + readonly #configProvider: ConfigProvider; + + readonly #logger: ILogger; + + readonly #pendingSubscriptions: Map = new Map(); // subscriptionId -> subscriptionRequest + + /** + * Some dapps, like jup.ag, request a signature, but broadcast the transaction themselves. + * If that broadcast does not happen, we end up with a stale subscription in state, waiting for a notification that will never come. + * To avoid this, we set a default expiry time of 10 minutes, which is more than enough + * for Solana transactions to either be confirmed or failed. + */ + readonly #expiryMilliseconds = Duration.Minute * 10; + + constructor( + subscriptionService: SubscriptionService, + accountService: AccountsService, + transactionsService: TransactionsService, + analyticsService: AnalyticsService, + connection: SolanaConnection, + configProvider: ConfigProvider, + logger: ILogger, + ) { + this.#subscriptionService = subscriptionService; + this.#accountService = accountService; + this.#transactionsService = transactionsService; + this.#analyticsService = analyticsService; + this.#connection = connection; + this.#configProvider = configProvider; + this.#logger = createPrefixedLogger(logger, '[✍️ SignatureMonitor]'); + + this.#bindHandlers(); + } + + #bindHandlers(): void { + SUPPORTED_NETWORKS.forEach((network) => { + this.#subscriptionService.registerNotificationHandler( + 'signatureSubscribe', + network, + this.#handleSignatureNotification.bind(this), + ); + this.#subscriptionService.registerConnectionRecoveryHandler( + network, + this.#handleConnectionRecovery.bind(this), + ); + }); + } + + /** + * Monitors a user's signature for a given network, and handles side effects + * when the transaction with the given signature reaches the specified + * commitment level. + * + * It subscribes to the RPC WebSocket API, to receive a notification + * when the transaction with the given signature reaches the specified + * commitment level. + * + * When it does, it unsubscribes from the RPC WebSocket API, and executes the + * given callback. + * + * It recovers from any missed notifications by directly fetching the + * confirmation status of the signature from the RPC HTTP API. + * + * @see https://solana.com/docs/rpc/websocket/signaturesubscribe + * @param signature - The signature to monitor. + * @param accountId - The account ID to monitor. + * @param commitment - The commitment level to monitor. + * @param network - The network to monitor. + * @param origin - The origin of the transaction. + */ + async monitor( + signature: string, + accountId: string, + commitment: Commitment, + network: Network, + origin: string, + ): Promise { + this.#logger.info(`Monitoring signature`, { + signature, + accountId, + commitment, + network, + origin, + }); + + const subscriptionRequest: SubscriptionRequest = { + method: 'signatureSubscribe', + network, + expiryMilliseconds: this.#expiryMilliseconds, + params: [ + signature, + { + commitment, + enableReceivedNotification: false, + }, + ], + metadata: { + accountId, + origin, + }, + }; + + const subscriptionId = + await this.#subscriptionService.subscribe(subscriptionRequest); + + this.#pendingSubscriptions.set(subscriptionId, subscriptionRequest); + } + + async #handleSignatureNotification( + _notification: SignatureNotification, + subscription: Subscription, + ): Promise { + try { + /** + * We don't need need to compare the commitment with the confirmation status. + * By design of the RPC API, if we receive a notification, then it means + * that the transaction has reached the desired commitment. + */ + + const { network } = subscription; + + const signature = get(subscription, 'params[0]'); + assert(signature, string()); + + const commitment = get(subscription, 'params[1].commitment'); + assert(commitment, CommitmentStruct); + + const accountId = subscription.metadata?.accountId; + assert(accountId, string()); + + const origin = subscription.metadata?.origin; + assert(origin, string()); + + const account = await this.#accountService.findById(accountId); + if (!account) { + throw new Error(`Account not found: ${accountId}`); + } + + const transaction = await this.#transactionsService.fetchBySignature( + signature, + account, + network, + ); + if (!transaction) { + throw new Error( + `Transaction with signature ${signature} not found on network ${network}`, + ); + } + + await this.#transactionsService.save(transaction); + + switch (commitment) { + case 'confirmed': + case 'finalized': + await this.#analyticsService.trackEventTransactionFinalized( + account, + transaction, + { + scope: network, + origin, + }, + ); + break; + default: + this.#logger.warn(`⚠️ Commitment ${commitment} not supported`); + } + } catch (error) { + await trackError(error); + this.#logger.error('Error handling signature notification', error); + } finally { + // Always unsubscribe and clean up, regardless of success or failure + await this.#subscriptionService.unsubscribe(subscription.id); + + this.#pendingSubscriptions.delete(subscription.id); + } + } + + async #handleConnectionRecovery(): Promise { + this.#logger.info('Handling connection recovery'); + + await Promise.all( + Array.from(this.#pendingSubscriptions.entries()).map( + async ([subscriptionId, subscriptionRequest]) => { + await this.#recoverSignatureSubscription( + subscriptionRequest, + subscriptionId, + ); + }, + ), + ); + } + + async #recoverSignatureSubscription( + subscriptionRequest: SubscriptionRequest, + subscriptionId: string, + ): Promise { + try { + const { network } = subscriptionRequest; + assert(network, string()); + + const signature = get(subscriptionRequest, 'params[0]'); + assert(signature, string()); + + const commitment = get(subscriptionRequest, 'params[1].commitment'); + assert(commitment, CommitmentStruct); + + const confirmationStatus = await this.#fetchConfirmationStatus( + signature, + network, + ); + if (!confirmationStatus) { + throw new Error( + `Signature ${signature} not found via HTTP fetch during connection recovery`, + ); + } + + if (!this.#hasAtLeastCommitmentLevel(confirmationStatus, commitment)) { + this.#logger.info( + 'Signature did not reach the desired commitment while connection was down. Skipping.', + ); + return; + } + + // If the signature reached the desired commitment, we simulate a notification, so that it's handled. + const fakeNotification: SignatureNotification = { + jsonrpc: '2.0', + method: 'signatureNotification', + params: { + subscription: 1, + result: { + context: { slot: BigInt(0) }, + // eslint-disable-next-line id-denylist + value: { err: null }, + }, + }, + }; + + const fakeSubscription: Subscription = { + ...subscriptionRequest, + id: subscriptionId, + } as Subscription; + + await this.#handleSignatureNotification( + fakeNotification, + fakeSubscription, + ); + + this.#pendingSubscriptions.delete(subscriptionId); + } catch (error) { + this.#logger.error('Error handling connection recovery', error); + } + } + + /** + * Fetches the status of a signature from the RPC API. + * + * @see https://docs.solana.com/developing/clients/jsonrpc-api#gettransaction + * @param signature - The signature to fetch the status of. + * @param network - The network to fetch the status from. + * @returns The status of the signature. + */ + async #fetchConfirmationStatus( + signature: string, + network: Network, + ): Promise { + const confirmationStatuses = await this.#connection + .getRpc(network) + .getSignatureStatuses([asSignature(signature)], { + searchTransactionHistory: true, + }) + .send(); + + const confirmationStatus = + confirmationStatuses.value[0]?.confirmationStatus; + + if (!confirmationStatus) { + throw new Error( + `Signature ${signature} not found via HTTP fetch during connection recovery`, + ); + } + + return confirmationStatus; + } + + /** + * Checks if the commitment level is at least the minimum level, + * based on the hierarchy of commitment levels `processed < confirmed < finalized`. + * + * @param commitment - The commitment level to check. + * @param minimumLevel - The minimum level to check against. + * @returns True if the commitment level is at least the minimum level, false otherwise. + */ + #hasAtLeastCommitmentLevel( + commitment: Commitment, + minimumLevel: Commitment, + ): boolean { + return ( + minimumLevel === commitment || + (minimumLevel === 'processed' && + (commitment === 'confirmed' || commitment === 'finalized')) || + (minimumLevel === 'confirmed' && commitment === 'finalized') + ); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts new file mode 100644 index 00000000..53632973 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts @@ -0,0 +1,165 @@ +import type { Subscription } from '../../../entities'; +import { Network } from '../../constants/solana'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; +import { SubscriptionRepository } from './SubscriptionRepository'; + +const createMockSubscription = (id: string): Subscription => ({ + id, + network: Network.Mainnet, + status: 'pending', + requestId: id, + method: 'accountSubscribe', + params: [], + createdAt: new Date().toISOString(), +}); + +describe('SubscriptionRepository', () => { + let repository: SubscriptionRepository; + let mockStateManager: IStateManager; + + beforeEach(() => { + mockStateManager = { + get: jest.fn(), + getKey: jest.fn(), + setKey: jest.fn(), + setKeyWith: jest.fn(), + update: jest.fn(), + deleteKey: jest.fn(), + deleteKeys: jest.fn(), + }; + + repository = new SubscriptionRepository(mockStateManager); + }); + + describe('getAll', () => { + it('returns an empty array if there are no subscriptions', async () => { + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue({}); + + const subscriptions = await repository.getAll(); + expect(subscriptions).toStrictEqual([]); + }); + + it('returns the subscriptions if there are any', async () => { + const subscription0 = createMockSubscription('0'); + const subscription1 = createMockSubscription('1'); + + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue({ + [subscription0.id]: subscription0, + [subscription1.id]: subscription1, + }); + + const subscriptions = await repository.getAll(); + expect(subscriptions).toStrictEqual([subscription0, subscription1]); + }); + + it('returns empty array if there are no subscriptions', async () => { + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue(undefined); + + const subscriptions = await repository.getAll(); + expect(subscriptions).toStrictEqual([]); + }); + }); + + describe('getById', () => { + it('returns the subscription if it exists', async () => { + const subscription = createMockSubscription('0'); + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue(subscription); + + const foundSubscription = await repository.getById(subscription.id); + expect(foundSubscription).toStrictEqual(subscription); + }); + }); + + describe('save', () => { + it('saves the subscription', async () => { + const subscription = createMockSubscription('0'); + + await repository.save(subscription); + expect(mockStateManager.setKey).toHaveBeenCalledWith( + 'subscriptions.0', + subscription, + ); + }); + }); + + describe('delete', () => { + it('deletes the subscription', async () => { + const subscription = createMockSubscription('0'); + + await repository.delete(subscription.id); + expect(mockStateManager.deleteKey).toHaveBeenCalledWith( + 'subscriptions.0', + ); + }); + }); + + describe('deleteMany', () => { + it('deletes the subscriptions', async () => { + const subscription0 = createMockSubscription('0'); + const subscription1 = createMockSubscription('1'); + + await repository.deleteMany([subscription0.id, subscription1.id]); + expect(mockStateManager.deleteKeys).toHaveBeenCalledWith([ + 'subscriptions.0', + 'subscriptions.1', + ]); + }); + }); + + describe('update', () => { + it('updates the subscription', async () => { + const subscription = createMockSubscription('0'); + + await repository.update(subscription); + expect(mockStateManager.setKey).toHaveBeenCalledWith( + 'subscriptions.0', + subscription, + ); + }); + }); + + describe('findBy', () => { + it('finds the subscription by the requestId', async () => { + const subscription = createMockSubscription('0'); + + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue({ + [subscription.id]: subscription, + }); + + const foundSubscription = await repository.findBy( + 'requestId', + subscription.requestId, + ); + + expect(foundSubscription).toStrictEqual(subscription); + }); + + it('returns undefined if the subscription is not found', async () => { + const subscription = createMockSubscription('0'); + + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue({ + [subscription.id]: subscription, + }); + + // Try to find a different subscription + const foundSubscription = await repository.findBy('requestId', '1'); + + expect(foundSubscription).toBeUndefined(); + }); + + it('returns undefined if there are no subscriptions', async () => { + jest.spyOn(mockStateManager, 'getKey').mockResolvedValue({}); + + const foundSubscription = await repository.findBy('requestId', '1'); + expect(foundSubscription).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('clears all subscriptions', async () => { + await repository.clear(); + expect(mockStateManager.deleteKey).toHaveBeenCalledWith('subscriptions'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts new file mode 100644 index 00000000..d128faad --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts @@ -0,0 +1,79 @@ +import type { + ConfirmedSubscription, + PendingSubscription, + Subscription, +} from '../../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class SubscriptionRepository { + readonly #state: IStateManager; + + readonly #stateKey = 'subscriptions'; + + constructor(state: IStateManager) { + this.#state = state; + } + + async getAll(): Promise { + const subscriptionsById = await this.#state.getKey< + Record + >(`${this.#stateKey}`); + + return Object.values(subscriptionsById ?? {}); + } + + async getById(subscriptionId: string): Promise { + const result = await this.#state.getKey< + UnencryptedStateValue['subscriptions'][string] + >(`${this.#stateKey}.${subscriptionId}`); + + return result; + } + + async save(subscription: Subscription): Promise { + await this.#state.setKey( + `${this.#stateKey}.${subscription.id}`, + subscription, + ); + } + + async delete(subscriptionId: string): Promise { + await this.#state.deleteKey(`${this.#stateKey}.${subscriptionId}`); + } + + async deleteMany(subscriptionIds: string[]): Promise { + const keys = subscriptionIds.map( + (subscriptionId) => `${this.#stateKey}.${subscriptionId}`, + ); + await this.#state.deleteKeys(keys); + } + + async update(subscription: Subscription): Promise { + await this.#state.setKey( + `${this.#stateKey}.${subscription.id}`, + subscription, + ); + } + + async findBy( + key: keyof PendingSubscription | keyof ConfirmedSubscription, + value: string | number, + ): Promise { + const subscriptions = await this.getAll(); + + if (subscriptions.length === 0) { + return undefined; + } + + return subscriptions.find( + (subscription) => + key in subscription && + subscription[key as keyof Subscription] === value, + ); + } + + async clear(): Promise { + await this.#state.deleteKey(`${this.#stateKey}`); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.test.ts new file mode 100644 index 00000000..1e09bef8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.test.ts @@ -0,0 +1,1145 @@ +import type { WebSocketMessage } from '@metamask/snaps-sdk'; +import { Duration } from '@metamask/utils'; + +import type { + ConfirmedSubscription, + ConnectionRecoveryHandler, + PendingSubscription, + Subscription, + SubscriptionRequest, + WebSocketConnection, +} from '../../../entities'; +import { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import { mockLogger } from '../mocks/logger'; +import type { SubscriptionRepository } from './SubscriptionRepository'; +import { SubscriptionService } from './SubscriptionService'; +import type { WebSocketConnectionService } from './WebSocketConnectionService'; + +const createMockSubscriptionRequest = ( + method = 'accountSubscribe' as const, + params = [], + network = Network.Mainnet, +): SubscriptionRequest => ({ + method, + params, + network, +}); + +const createMockConfirmationMessage = ( + id: string = globalThis.crypto.randomUUID(), + rpcSubscriptionId = 98765, +): WebSocketMessage => ({ + type: 'message', + id, + origin: 'some-origin', + data: { + type: 'text', + message: JSON.stringify({ + jsonrpc: '2.0', + id, + result: rpcSubscriptionId, + }), + }, +}); + +const createMockUnsubscriptionConfirmationMessage = ( + id: string = globalThis.crypto.randomUUID(), +) => ({ + type: 'message', + id, + data: { + type: 'text', + message: JSON.stringify({ jsonrpc: '2.0', id, result: true }), + }, +}); + +const createMockNotificationMessage = ( + id: string = globalThis.crypto.randomUUID(), + rpcSubscriptionId = 98765, + result: any = {}, +) => ({ + type: 'message', + id, + origin: 'some-origin', + data: { + type: 'text', + message: JSON.stringify({ + jsonrpc: '2.0', + method: 'accountNotification', + params: { subscription: rpcSubscriptionId, result }, + }), + }, +}); + +const createMockFailureMessage = ( + error: any, + id: string = globalThis.crypto.randomUUID(), +) => ({ + type: 'message', + id, + data: { + type: 'text', + message: JSON.stringify({ + jsonrpc: '2.0', + id, + error, + }), + }, +}); + +const simulateDisconnection = async ( + eventEmitter: EventEmitter, + connectionId: string, +) => { + await eventEmitter.emitSync('onWebSocketEvent', { + event: { + type: 'close', + id: connectionId, + }, + }); +}; + +const simulateReconnection = async ( + eventEmitter: EventEmitter, + connectionId: string, +) => { + await eventEmitter.emitSync('onWebSocketEvent', { + event: { + type: 'open', + id: connectionId, + }, + }); +}; + +const triggerConnectionRecoveryHandlers = async ( + connectionRecoveryHandlers: Map, + network: Network, +) => { + const recoveryHandlers = connectionRecoveryHandlers.get(network) ?? []; + await Promise.all( + recoveryHandlers.map(async (handler) => { + await handler(network); + }), + ); +}; + +describe('SubscriptionService', () => { + let service: SubscriptionService; + let mockWebSocketConnectionService: WebSocketConnectionService; + let mockSubscriptionRepository: SubscriptionRepository; + let mockEventEmitter: EventEmitter; + const connectionRecoveryHandlers: Map = + new Map(); + + const mockNetwork = Network.Mainnet; + const mockConnectionId = 'some-connection-id'; + + const mockConnection: WebSocketConnection = { + id: mockConnectionId, + url: 'wss://some-url', + protocols: ['some-protocol'], + network: mockNetwork, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + + mockWebSocketConnectionService = { + openConnection: jest.fn(), + findById: jest.fn().mockReturnValue(mockConnection), + findByNetwork: jest.fn().mockReturnValue(mockConnection), + // Allows to capture the connection recovery handlers to trigger them manually + onConnectionRecovery: jest.fn().mockImplementation((network, handler) => { + connectionRecoveryHandlers.set(network, [ + ...(connectionRecoveryHandlers.get(network) ?? []), + handler, + ]); + }), + handleConnectionEvent: jest.fn(), + } as unknown as WebSocketConnectionService; + + mockSubscriptionRepository = { + getAll: jest.fn().mockResolvedValue([]), + getById: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + findBy: jest.fn(), + } as unknown as SubscriptionRepository; + + mockEventEmitter = new EventEmitter(mockLogger); + + service = new SubscriptionService( + mockWebSocketConnectionService, + mockSubscriptionRepository, + mockEventEmitter, + mockLogger, + ); + }); + + describe('subscribe', () => { + it('persists the subscription in state', async () => { + const request = createMockSubscriptionRequest(); + + await service.subscribe(request); + + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + }), + ); + }); + + describe('when the connection is open', () => { + it('sends a subscribe message', async () => { + jest.spyOn(snap, 'request').mockResolvedValueOnce(null); + const request = createMockSubscriptionRequest(); + + const subscriptionId = await service.subscribe(request); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + method: 'accountSubscribe', + params: [], + }), + }, + }); + }); + + it('returns the same ID for the same request', async () => { + const request = createMockSubscriptionRequest(); + const subscriptionId1 = await service.subscribe(request); + const subscriptionId2 = await service.subscribe(request); + + expect(subscriptionId1).toBe(subscriptionId2); + }); + + it('does not create duplicate subscriptions when the first one is confirmed', async () => { + const request = createMockSubscriptionRequest(); + const subscriptionId1 = await service.subscribe(request); + + // Mock the repository to return the first subscription as confirmed + jest.spyOn(mockSubscriptionRepository, 'getById').mockResolvedValue({ + ...request, + id: subscriptionId1, + status: 'confirmed', + } as ConfirmedSubscription); + + const subscriptionId2 = await service.subscribe(request); + const subscriptionId3 = await service.subscribe(request); + + expect(subscriptionId1).toBe(subscriptionId2); + expect(subscriptionId2).toBe(subscriptionId3); + expect(mockSubscriptionRepository.save).toHaveBeenCalledTimes(1); + }); + + it('generates IDs that are independent of the request key order', async () => { + const request1 = { + method: 'accountSubscribe' as const, + network: Network.Mainnet, + params: [ + { + commitment: 'confirmed', + encoding: 'jsonParsed', + }, + ], + }; + + // Same request, but with the keys in a different order + const request2 = { + network: Network.Mainnet, + method: 'accountSubscribe' as const, + params: [ + { + encoding: 'jsonParsed', + commitment: 'confirmed', + }, + ], + }; + + const subscriptionId1 = await service.subscribe(request1); + const subscriptionId2 = await service.subscribe(request2); + + expect(subscriptionId1).toBe(subscriptionId2); + }); + + it('generates IDs that are different for requests with different keys', async () => { + const request1 = { + method: 'accountSubscribe' as const, + network: Network.Mainnet, + params: [ + { + commitment: 'confirmed', + encoding: 'jsonParsed', + }, + ], + }; + + const request2 = { + method: 'accountSubscribe' as const, + network: Network.Mainnet, + params: [ + { + commitment: 'finalized', + encoding: 'jsonParsed', + }, + ], + }; + + const subscriptionId1 = await service.subscribe(request1); + const subscriptionId2 = await service.subscribe(request2); + + expect(subscriptionId1).not.toBe(subscriptionId2); + }); + + it('deletes the stale pending subscription when re-subscribing', async () => { + const request = createMockSubscriptionRequest(); + const subscriptionId1 = await service.subscribe(request); + + const mockPendingSubscription1: PendingSubscription = { + ...request, + id: subscriptionId1, + status: 'pending', + requestId: subscriptionId1, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith({ + ...mockPendingSubscription1, + createdAt: expect.any(String), + }); + + // Mock the repository to return the first subscription as pending + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(mockPendingSubscription1); + + await service.subscribe(request); + + const mockPendingSubscription2: PendingSubscription = { + ...request, + id: subscriptionId1, + status: 'pending', + requestId: subscriptionId1, + createdAt: '2024-01-01T01:00:00.000Z', + }; + + expect(mockSubscriptionRepository.delete).toHaveBeenCalledWith( + subscriptionId1, + ); + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith({ + ...mockPendingSubscription2, + createdAt: expect.any(String), + }); + }); + }); + + // See 'complex flows' below for when the connection is not (yet) open. + }); + + describe('unsubscribe', () => { + it('does nothing when the subscription does not exist', async () => { + await service.unsubscribe('some-inexistent-id'); + + // There was no subscription so there shouldn't be a call to unsubscribe. + expect(snap.request).not.toHaveBeenCalled(); + }); + + it('unsubscribes from an active subscription', async () => { + const mockSubscriptionId = 'some-subscription-id'; + const mockConfirmedSubscription: ConfirmedSubscription = { + ...createMockSubscriptionRequest(), + id: mockSubscriptionId, + status: 'confirmed', + requestId: mockSubscriptionId, + rpcSubscriptionId: 98765, + createdAt: '2024-01-01T00:00:00.000Z', + confirmedAt: '2024-01-02T00:00:00.000Z', + }; + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(mockConfirmedSubscription); + + // Unsubscribe + await service.unsubscribe('some-subscription-id'); + + // Verify unsubscribe was called + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: expect.stringContaining('"method":"accountUnsubscribe"'), + }, + }); + }); + + it('deletes immediately the subscription from the repository', async () => { + const mockSubscriptionId = 'some-subscription-id'; + const mockConfirmedSubscription: ConfirmedSubscription = { + ...createMockSubscriptionRequest(), + id: mockSubscriptionId, + status: 'confirmed', + requestId: mockSubscriptionId, + rpcSubscriptionId: 98765, + createdAt: '2024-01-01T00:00:00.000Z', + confirmedAt: '2024-01-02T00:00:00.000Z', + }; + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(mockConfirmedSubscription); + + // Unsubscribe + await service.unsubscribe(mockSubscriptionId); + + // Verify the subscription was deleted + expect(mockSubscriptionRepository.delete).toHaveBeenCalledWith( + mockSubscriptionId, + ); + }); + }); + + describe('#handleWebSocketEvent', () => { + it('returns without failing when there is no connection', async () => { + jest + .spyOn(mockWebSocketConnectionService, 'findById') + .mockResolvedValue(null); + + expect( + await mockEventEmitter.emitSync('onWebSocketEvent', {}), + ).toBeUndefined(); + }); + + describe('when the message is a notification', () => { + describe('when there is no confirmed subscription for the message', () => { + it('returns without failing', async () => { + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(undefined); + const notification = createMockNotificationMessage(); + + expect( + await mockEventEmitter.emitSync('onWebSocketEvent', notification), + ).toBeUndefined(); + }); + }); + + describe('when there is a confirmed subscription for the message', () => { + let request: SubscriptionRequest; + let confirmedSubscription: ConfirmedSubscription; + + beforeEach(async () => { + request = createMockSubscriptionRequest(); + + const subscriptionId = await service.subscribe(request); + + const confirmationMessage = createMockConfirmationMessage( + subscriptionId, + 98765, + ); + + await mockEventEmitter.emitSync('onWebSocketEvent', { + event: confirmationMessage, + }); + + confirmedSubscription = { + ...request, + id: subscriptionId, + rpcSubscriptionId: 98765, + status: 'confirmed', + requestId: subscriptionId, + createdAt: '2024-01-01T00:00:00.000Z', + confirmedAt: '2024-01-02T00:00:00.000Z', + }; + + jest + .spyOn(mockSubscriptionRepository, 'findBy') + .mockResolvedValue(confirmedSubscription); + }); + + it('call the registered handler when a notification is received', async () => { + // Register a handler for the notification. + const handler = jest.fn(); + service.registerNotificationHandler( + 'accountSubscribe', + mockNetwork, + handler, + ); + + const notification = createMockNotificationMessage( + undefined, + undefined, + { + context: { Slot: 348893275 }, + value: { lamports: 116044436802 }, + }, + ); + + await mockEventEmitter.emitSync('onWebSocketEvent', notification); + + expect(handler).toHaveBeenCalledWith( + { + jsonrpc: '2.0', + method: 'accountNotification', + params: { + subscription: 98765, + result: { + context: { Slot: 348893275 }, + value: { lamports: 116044436802 }, + }, + }, + }, + confirmedSubscription, + ); + }); + + it('catches errors with failing handlers', async () => { + const handler = jest.fn().mockRejectedValue(new Error('Error')); + service.registerNotificationHandler( + 'accountSubscribe', + mockNetwork, + handler, + ); + + const notification = createMockNotificationMessage( + undefined, + undefined, + { + context: { Slot: 348893275 }, + value: { lamports: 116044436802 }, + }, + ); + + expect( + await mockEventEmitter.emitSync('onWebSocketEvent', notification), + ).toBeUndefined(); + }); + }); + }); + + describe('when the message is a subscription confirmation', () => { + describe('when there is no subscription for the message', () => { + it('does not update the subscription', async () => { + const message = createMockConfirmationMessage('some-subscription-id'); + + await mockEventEmitter.emitSync('onWebSocketEvent', message); + + expect(mockSubscriptionRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe('when there is a pending subscription for the message', () => { + let request: SubscriptionRequest; + let subscriptionId: string; + let pendingSubscription: PendingSubscription; + + beforeEach(async () => { + request = createMockSubscriptionRequest(); + subscriptionId = await service.subscribe(request); + + pendingSubscription = { + ...request, + id: subscriptionId, + status: 'pending', + requestId: subscriptionId, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(pendingSubscription); + }); + + it('confirms the subscription', async () => { + const confirmationMessage = createMockConfirmationMessage(); + + await mockEventEmitter.emitSync( + 'onWebSocketEvent', + confirmationMessage, + ); + + // Verify the confirmation was updated to 'confirmed' + const confirmedSubscription: ConfirmedSubscription = { + ...pendingSubscription, + status: 'confirmed', + rpcSubscriptionId: 98765, + confirmedAt: expect.any(String), + }; + + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith( + confirmedSubscription, + ); + + // Now we'll verify that notifications are now handled + + // Register a handler for the notification. We expect it to be called. + const handler = jest.fn(); + service.registerNotificationHandler( + 'accountSubscribe', + mockNetwork, + handler, + ); + + jest + .spyOn(mockSubscriptionRepository, 'findBy') + .mockResolvedValue(confirmedSubscription); + + const notification = createMockNotificationMessage( + confirmedSubscription.id, + 98765, + { + context: { Slot: 348893275 }, + value: { lamports: 116044436802 }, + }, + ); + + await mockEventEmitter.emitSync('onWebSocketEvent', notification); + + const expectedNotification = { + jsonrpc: '2.0', + method: 'accountNotification', + params: { + result: { + context: { Slot: 348893275 }, + value: { lamports: 116044436802 }, + }, + subscription: 98765, + }, + }; + + expect(handler).toHaveBeenCalledWith( + expectedNotification, + confirmedSubscription, + ); + }); + }); + }); + + describe('when the message is an unsubscription confirmation', () => { + it('does nothing since subscription was already deleted', async () => { + const message = createMockUnsubscriptionConfirmationMessage( + 'some-rpc-unsubscription-id', + ); + const mockSubscription: ConfirmedSubscription = { + id: 'some-subscription-id', + status: 'confirmed', + } as ConfirmedSubscription; + jest + .spyOn(mockSubscriptionRepository, 'findBy') + .mockResolvedValue(mockSubscription); + + await mockEventEmitter.emitSync('onWebSocketEvent', message); + + // Should not try to delete again + expect(mockSubscriptionRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when the message is a failure', () => { + describe('when it is a response to a specific request', () => { + const message = createMockFailureMessage({ + code: -32000, + message: 'Subscription error', + }); + + describe('when there is a pending subscription for the message', () => { + let request: SubscriptionRequest; + let subscriptionId: string; + + beforeEach(async () => { + request = createMockSubscriptionRequest(); // request ID is 2 (request ID 1 was for opening the connection), hence why we createMockFailure with 2 as first argument + subscriptionId = await service.subscribe(request); + + const pendingSubscription: PendingSubscription = { + ...request, + id: subscriptionId, + status: 'pending', + requestId: subscriptionId, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + jest + .spyOn(mockSubscriptionRepository, 'getById') + .mockResolvedValue(pendingSubscription); + }); + + it('deletes the pending subscription', async () => { + await mockEventEmitter.emitSync('onWebSocketEvent', message); + + expect(mockSubscriptionRepository.delete).toHaveBeenCalledWith( + subscriptionId, + ); + }); + }); + + describe('when there is no pending subscription for the message', () => { + it('does nothing', async () => { + await mockEventEmitter.emitSync('onWebSocketEvent', message); + + expect(mockSubscriptionRepository.delete).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when it is a connection-level error', () => { + const message = { + type: 'message', + id: 'some-id', + origin: 'some-origin', + data: { + type: 'text', + message: JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32700, + message: 'Parse error', + }, + }), + }, + }; + + it('does nothing', async () => { + await mockEventEmitter.emitSync('onWebSocketEvent', message); + + expect(mockSubscriptionRepository.delete).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('re-subscription scenarios', () => { + describe('when a subscription request is sent, but no open connection', () => { + it('saves the subscription request, and sends it once the connection is opened', async () => { + // Mock findByNetwork to return null initially, then connection for re-subscription + jest + .spyOn(mockWebSocketConnectionService, 'findByNetwork') + .mockResolvedValueOnce(null) // First call during subscribe() returns null + .mockResolvedValue(mockConnection); // All subsequent calls return connection + + const request = createMockSubscriptionRequest(); + const subscriptionId = await service.subscribe(request); + + const pendingSubscription: PendingSubscription = { + ...request, + id: subscriptionId, + status: 'pending', + requestId: subscriptionId, + createdAt: expect.any(String), + }; + + // Verify that the subscription request was saved, but not sent yet + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( + pendingSubscription, + ); + expect(snap.request).not.toHaveBeenCalled(); + + // Mock getAll to return the saved subscription for re-subscription + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([pendingSubscription]); + + // Mock deleteMany for re-subscription flow + jest + .spyOn(mockSubscriptionRepository, 'deleteMany') + .mockResolvedValue(); + + // Send the connection event + await simulateReconnection(mockEventEmitter, mockConnectionId); + + // Manually trigger the connection recovery handlers + await triggerConnectionRecoveryHandlers( + connectionRecoveryHandlers, + mockNetwork, + ); + + // Verify that the subscription request was sent during re-subscription + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: expect.stringContaining('"method":"accountSubscribe"'), + }, + }); + }); + }); + + describe('when the connection is lost BEFORE the subscription is confirmed', () => { + it('re-sends the subscription request when the connection is reestablished', async () => { + const request = createMockSubscriptionRequest(); + const subscriptionId = await service.subscribe(request); + + const pendingSubscription: PendingSubscription = { + ...request, + id: subscriptionId, + status: 'pending', + requestId: subscriptionId, + createdAt: expect.any(String), + }; + + // Verify that the subscription request was saved + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( + pendingSubscription, + ); + + // Verify that the subscription request was sent + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + method: 'accountSubscribe', + params: [], + }), + }, + }); + + // Do not confirm the subscription yet + + // Now, simulate a disconnection (this has no direct effect, it's just for clarity of the test) + await simulateDisconnection(mockEventEmitter, mockConnectionId); + + // Simulate a reconnection + await simulateReconnection(mockEventEmitter, mockConnectionId); + + // Manually trigger the connection recovery callbacks since we can't mock the private method #handleWebSocketEvent + await triggerConnectionRecoveryHandlers( + connectionRecoveryHandlers, + mockNetwork, + ); + + // Verify that the subscription request was sent again + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + method: 'accountSubscribe', + params: [], + }), + }, + }); + }); + }); + + describe('when the connection is lost AFTER the subscription is confirmed', () => { + it('re-sends the subscription request when the connection is reestablished', async () => { + const request = createMockSubscriptionRequest(); + const subscriptionId = await service.subscribe(request); + + const pendingSubscription: PendingSubscription = { + ...request, + id: subscriptionId, + status: 'pending', + requestId: subscriptionId, + createdAt: expect.any(String), + }; + + // Verify that the subscription request was saved + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( + pendingSubscription, + ); + + // Verify that the connection request was sent + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + method: 'accountSubscribe', + params: [], + }), + }, + }); + + // Confirm the subscription + const confirmationMessage = createMockConfirmationMessage( + subscriptionId, + 98765, + ); + await mockEventEmitter.emitSync( + 'onWebSocketEvent', + confirmationMessage, + ); + + jest.spyOn(mockSubscriptionRepository, 'findBy').mockResolvedValue({ + ...pendingSubscription, + status: 'confirmed', + rpcSubscriptionId: 98765, + confirmedAt: expect.any(String), + }); + + // Now, simulate a disconnection (this has no direct effect, it's just for clarity of the test) + await simulateDisconnection(mockEventEmitter, mockConnectionId); + + // Simulate a reconnection + await simulateReconnection(mockEventEmitter, mockConnectionId); + + // Verify that the subscription request was sent again + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_sendWebSocketMessage', + params: { + id: mockConnectionId, + message: JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + method: 'accountSubscribe', + params: [], + }), + }, + }); + }); + }); + }); + + describe('expiry', () => { + const mockNowTimestamp = 1736660000; // 2025-01-12T05:33:20.000Z + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(mockNowTimestamp * 1000)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('saves the subscription with an expiry date', async () => { + const request = createMockSubscriptionRequest(); + request.expiryMilliseconds = Duration.Second * 5; + + await service.subscribe(request); + + expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + expiresAt: '2025-01-12T05:33:25.000Z', + }), + ); + }); + + it('unsubscribes expired subscriptions when the client becomes inactive', async () => { + // Set up a subscription that will expire soon + const subscription = { + id: 'expires-soon', + expiresAt: new Date(Date.now() + Duration.Second * 5).toISOString(), + } as unknown as Subscription; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([subscription]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + // Time passes... + jest.advanceTimersByTime(Duration.Minute * 10); + + // Simulate an onInactive event + await mockEventEmitter.emitSync('onInactive'); + + expect(unsubscribeSpy).toHaveBeenCalledWith('expires-soon'); + }); + + it('does not unsubscribe subscriptions that are not expired', async () => { + // Set up a subscription that will expire in a long time + const subscription = { + id: 'active-1', + expiresAt: new Date(Date.now() + Duration.Hour).toISOString(), + } as unknown as Subscription; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([subscription]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + // Not enough time passes... + jest.advanceTimersByTime(Duration.Minute * 10); + + // Simulate an onInactive event + await mockEventEmitter.emitSync('onInactive'); + + expect(unsubscribeSpy).not.toHaveBeenCalledWith('active-1'); + }); + + it('does not unsubscribe subscriptions with no expiry date', async () => { + const subscription = { + id: 'permanent-1', + expiresAt: undefined, + } as unknown as Subscription; + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([subscription]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + // Time passes... + jest.advanceTimersByTime(Duration.Hour); + + // Simulate an onInactive event + await mockEventEmitter.emitSync('onInactive'); + + expect(unsubscribeSpy).not.toHaveBeenCalledWith('permanent-1'); + }); + + it('handles mixed expired and non-expired subscriptions correctly', async () => { + const expiredSub1 = { + id: 'expired-1', + expiresAt: new Date(Date.now() + Duration.Second * 5).toISOString(), + } as unknown as Subscription; + + const expiredSub2 = { + id: 'expired-2', + expiresAt: new Date(Date.now() + Duration.Second * 10).toISOString(), + } as unknown as Subscription; + + const activeSub = { + id: 'active-1', + expiresAt: new Date(Date.now() + Duration.Hour).toISOString(), + } as unknown as Subscription; + + const permanentSub = { + id: 'permanent-1', + expiresAt: undefined, + } as unknown as Subscription; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([expiredSub1, expiredSub2, activeSub, permanentSub]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + jest.advanceTimersByTime(Duration.Minute * 10); + await mockEventEmitter.emitSync('onInactive'); + + expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + expect(unsubscribeSpy).toHaveBeenCalledWith('expired-1'); + expect(unsubscribeSpy).toHaveBeenCalledWith('expired-2'); + }); + + it('continues removing other expired subscriptions when one unsubscribe fails', async () => { + const expiredSub1 = { + id: 'expired-1', + expiresAt: new Date(Date.now() + Duration.Second * 5).toISOString(), + } as unknown as Subscription; + + const expiredSub2 = { + id: 'expired-2', + expiresAt: new Date(Date.now() + Duration.Second * 10).toISOString(), + } as unknown as Subscription; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([expiredSub1, expiredSub2]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValueOnce() // First call succeeds + .mockRejectedValueOnce(new Error('Unsubscribe failed')); // Second call fails + + jest.advanceTimersByTime(Duration.Minute * 10); + await mockEventEmitter.emitSync('onInactive'); + + // Should attempt both despite the failure + expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + expect(unsubscribeSpy).toHaveBeenCalledWith('expired-1'); + expect(unsubscribeSpy).toHaveBeenCalledWith('expired-2'); + }); + + it('handles empty subscription list gracefully', async () => { + jest.spyOn(mockSubscriptionRepository, 'getAll').mockResolvedValue([]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + await mockEventEmitter.emitSync('onInactive'); + + expect(unsubscribeSpy).not.toHaveBeenCalled(); + }); + + it('handles invalid expiry dates gracefully', async () => { + const subscription = { + id: 'invalid-expiry', + expiresAt: 'invalid-date-string', + } as unknown as Subscription; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([subscription]); + + const unsubscribeSpy = jest + .spyOn(service, 'unsubscribe') + .mockResolvedValue(); + + jest.advanceTimersByTime(Duration.Hour); + + // Should not crash and should not unsubscribe due to invalid date + expect(await mockEventEmitter.emitSync('onInactive')).toBeUndefined(); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + }); + + it('does not re-subscribe expired subscriptions after connection reestablishment', async () => { + const request = createMockSubscriptionRequest(); + request.expiryMilliseconds = Duration.Second * 5; + + const confirmedSubscription: ConfirmedSubscription = { + ...request, + id: 'some-subscription-id', + status: 'confirmed', + requestId: 'some-subscription-id', + rpcSubscriptionId: 98765, + createdAt: Date.now().toString(), + confirmedAt: Date.now().toString(), + expiresAt: new Date(Date.now() + Duration.Second * 5).toISOString(), + }; + + jest + .spyOn(mockSubscriptionRepository, 'getAll') + .mockResolvedValue([confirmedSubscription]); + + // Now, simulate a disconnection (this has no direct effect, it's just for clarity of the test) + await simulateDisconnection(mockEventEmitter, mockConnectionId); + + // Time passes... + jest.advanceTimersByTime(Duration.Minute * 10); + + const subscribeSpy = jest.spyOn(service, 'subscribe'); + + // Simulate a reconnection + await simulateReconnection(mockEventEmitter, mockConnectionId); + + // Manually trigger the connection recovery handlers + await triggerConnectionRecoveryHandlers( + connectionRecoveryHandlers, + mockNetwork, + ); + + // Verify that the subscription request was not sent again + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.ts new file mode 100644 index 00000000..19105126 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionService.ts @@ -0,0 +1,626 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { WebSocketEvent } from '@metamask/snaps-sdk'; +import { isJsonRpcFailure } from '@metamask/utils'; +import type { JsonRpcFailure } from '@metamask/utils'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import type { + Notification, + NotificationHandler, + PendingSubscription, + SubscribeMethod, + Subscription, + SubscriptionConfirmation, + SubscriptionRequest, + UnsubscriptionConfirmation, +} from '../../../entities'; +import { subscribeMethodToUnsubscribeMethod } from '../../../entities'; +import type { EventEmitter } from '../../../infrastructure'; +import type { Network } from '../../constants/solana'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { SUPPORTED_NETWORKS } from '../config/ConfigProvider'; +import { parseWebSocketMessage } from './parseWebSocketMessage'; +import type { SubscriptionRepository } from './SubscriptionRepository'; +import type { WebSocketConnectionService } from './WebSocketConnectionService'; + +/** + * Allows subscribing / unsubscribing from real-time notifications from the Solana blockchain using the [RPC WebSocket API](https://solana.com/docs/rpc/websocket). + * + * @example + * ```ts + * const service = new SubscriptionService(...); + * await service.subscribe(request); + * await service.unsubscribe(subscriptionId); + * ``` + */ +export class SubscriptionService { + readonly #connectionService: WebSocketConnectionService; + + readonly #subscriptionRepository: SubscriptionRepository; + + readonly #eventEmitter: EventEmitter; + + readonly #logger: ILogger; + + readonly #notificationHandlers: Map> = + new Map(); + + constructor( + connectionService: WebSocketConnectionService, + subscriptionRepository: SubscriptionRepository, + eventEmitter: EventEmitter, + logger: ILogger, + ) { + this.#connectionService = connectionService; + this.#subscriptionRepository = subscriptionRepository; + this.#eventEmitter = eventEmitter; + this.#logger = createPrefixedLogger(logger, '[🔔 SubscriptionService]'); + + this.#bindHandlers(); + } + + #bindHandlers(): void { + this.#eventEmitter.on('onInactive', this.#handleOnInactive.bind(this)); + + this.#eventEmitter.on( + 'onWebSocketEvent', + this.#handleWebSocketEvent.bind(this), + ); + + // Specific binds to enable manual testing from the test dapp + this.#eventEmitter.on( + 'onListSubscriptions', + this.#listSubscriptions.bind(this), + ); + + /** + * Register handlers that will automatically re-subscribe when the connection is reestablished. It covers both cases: + * - The connection was lost then re-established -> we need to re-subscribe. + * - The connection was not yet established, and we need to subscribe when it is established. + */ + + SUPPORTED_NETWORKS.forEach((network) => { + this.#connectionService.onConnectionRecovery( + network, + this.#reSubscribe.bind(this), + ); + }); + } + + /** + * Registers a handler for whenever a WebSocket notification is received for a given method and network. + * + * @param method - The method to register the handler for. + * @param network - The network to register the handler for. + * @param handler - The unified handler that receives notification and subscription. + */ + registerNotificationHandler( + method: SubscribeMethod, + network: Network, + handler: NotificationHandler, + ): void { + this.#logger.info(`Registering notification handler`, { + network, + method, + handler, + }); + + const key = `${method}:${network}`; + if (!this.#notificationHandlers.has(key)) { + this.#notificationHandlers.set(key, new Set()); + } + this.#notificationHandlers.get(key)!.add(handler); + } + + /** + * Registers a handler for whenever the connection is re-established. + * Simply re-exposes the method `onConnectionRecovery` from the connection service. + * + * @param network - The network to register the handler for. + * @param handler - The handler to register. + */ + registerConnectionRecoveryHandler( + network: Network, + handler: (network: Network) => Promise, + ): void { + this.#connectionService.onConnectionRecovery(network, handler); + } + + /** + * Requests a new subscription. + * - If the connection is already established, the subscription request is sent immediately. + * - If the connection is not established, the subscription request is saved and will be sent later, when the connection is established. + * - It re-subscribes automatically when the connection is re-established. + * + * Subscription is idempotent. If the same request is sent multiple times, it will only be saved once, + * and the returned ID is deterministic of the request: same request -> same ID. + * + * @param request - The subscription request. + * @returns The ID of the subscription. + */ + async subscribe(request: SubscriptionRequest): Promise { + this.#logger.info(`New subscription request`, request); + + const { method, params, network, expiryMilliseconds } = request; + + const id = await this.#generateId(request); + + // Check if a subscription with this ID already exists + const existingSubscription = await this.#subscriptionRepository.getById(id); + + if (existingSubscription) { + // If it's confirmed, just return the existing ID + if (existingSubscription.status === 'confirmed') { + return id; + } + + // If it's pending, delete and proceed to recreate, to handle stale pending subscriptions + await this.#subscriptionRepository.delete(id); + } + + const pendingSubscription: PendingSubscription = { + ...request, + id, + status: 'pending', + requestId: id, // Use the same ID for the request and the subscription for easier lookup. + createdAt: new Date().toISOString(), + ...(expiryMilliseconds + ? { + expiresAt: new Date(Date.now() + expiryMilliseconds).toISOString(), + } + : {}), + }; + + // Before sending the request, save the subscription in the repository. + // When it gets confirmed, we will update the status to 'active'. + await this.#subscriptionRepository.save(pendingSubscription); + + const connection = await this.#connectionService.findByNetwork(network); + + const sendSubscriptionMessage = async (_connectionId: string) => { + const message: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + params, + }; + if (_connectionId) { + await this.#sendMessage(_connectionId, message); + } + }; + + // If the connection is open, send the message immediately. + // If not, the subscription will be sent when the connection is reestablished via the #reSubscribe handler. + if (connection) { + await sendSubscriptionMessage(connection.id); + } + + return pendingSubscription.id; + } + + async unsubscribe(subscriptionId: string): Promise { + this.#logger.info(`Unsubscribing from`, subscriptionId); + + // Attempt to find the subscription in the repository + const subscription = + await this.#subscriptionRepository.getById(subscriptionId); + + if (!subscription) { + this.#logger.warn(`Subscription not found: ${subscriptionId}`); + return; + } + + const { id, network, method } = subscription; + const unsubscribeMethod = subscribeMethodToUnsubscribeMethod[method]; + + // Delete the subscription from the repository. + await this.#subscriptionRepository.delete(id); + + // If the subscription was active, we need to unsubscribe from the RPC + if (subscription.status === 'confirmed') { + const connection = await this.#connectionService.findByNetwork(network); + + if (connection) { + await this.#sendMessage(connection.id, { + jsonrpc: '2.0', + id: globalThis.crypto.randomUUID(), + method: unsubscribeMethod, + params: [subscription.rpcSubscriptionId], + }); + } + } + } + + async getAll(): Promise { + return this.#subscriptionRepository.getAll(); + } + + async #handleWebSocketEvent(message: WebSocketEvent): Promise { + // We only care about actual messages, not open or close events, which are handled by the connection service. + if (message.type !== 'message') { + return; + } + + const parsedMessage = parseWebSocketMessage(message); + const connection = await this.#connectionService.findById(message.id); + if (!connection) { + return; + } + + this.#logger.info(`Received message`, message); + + switch (parsedMessage.method) { + case 'accountNotification': + case 'programNotification': + case 'signatureNotification': + await this.#routeNotification( + parsedMessage as Notification, + connection.network, + ); + break; + default: + // Handle subscription confirmations/errors + if (this.#isSubscriptionConfirmation(parsedMessage)) { + await this.#handleSubscriptionConfirmation(parsedMessage); + } else if (this.#isUnsubscriptionConfirmation(parsedMessage)) { + await this.#handleUnsubscriptionConfirmation(parsedMessage); + } else if (isJsonRpcFailure(parsedMessage)) { + await this.#handleFailure(parsedMessage); + } else { + this.#logger.warn(`Received unknown message`, parsedMessage); + } + break; + } + } + + async #routeNotification( + notification: Notification, + network: Network, + ): Promise { + const { subscription: rpcSubscriptionId } = notification.params; + + const subscription = await this.#subscriptionRepository.findBy( + 'rpcSubscriptionId', + rpcSubscriptionId, + ); + if (!subscription) { + this.#logger.warn('No subscription found for RPC ID:', rpcSubscriptionId); + return; + } + + const key = `${subscription.method}:${network}`; + const handlers = this.#notificationHandlers.get(key); + + if (handlers && handlers.size > 0) { + const results = await Promise.allSettled( + Array.from(handlers).map(async (handler) => + handler(notification as any, subscription), + ), + ); + + // Log failures but don't stop other handlers + results.forEach((item) => { + if (item.status === 'rejected') { + this.#logger.error( + `Handler failed for ${subscription.method}:`, + item.reason, + ); + } + }); + } + } + + /** + * Sends a message to the WebSocket connection. + * + * @param connectionId - The ID of the connection to send the message to. + * @param message - The message to send. + * @returns A promise that resolves when the message is sent. + */ + async #sendMessage( + connectionId: string, + message: JsonRpcRequest, + ): Promise { + this.#logger.info(`Sending message to connection ${connectionId}`, message); + + await snap.request({ + method: 'snap_sendWebSocketMessage', + params: { + id: connectionId, + message: JSON.stringify(message), + }, + }); + } + + /** + * Checks if the message is a subscription confirmation. + * + * @param message - The message to check. + * @returns True if the message is a subscription confirmation, false otherwise. + */ + #isSubscriptionConfirmation( + message: any, + ): message is SubscriptionConfirmation { + return ( + 'jsonrpc' in message && + message.jsonrpc === '2.0' && + 'id' in message && + 'result' in message && + typeof message.result === 'number' + ); + } + + /** + * Checks if the message is a unsubscription confirmation. + * + * @param message - The message to check. + * @returns True if the message is a unsubscription confirmation, false otherwise. + */ + #isUnsubscriptionConfirmation( + message: any, + ): message is UnsubscriptionConfirmation { + return ( + 'jsonrpc' in message && + 'id' in message && + 'result' in message && + message.result === true + ); + } + + async #handleSubscriptionConfirmation( + message: SubscriptionConfirmation, + ): Promise { + const { id: requestId, result: rpcSubscriptionId } = message; // request ID and subscription ID are the same + + const subscription = await this.#subscriptionRepository.getById( + String(requestId), + ); + + if (!subscription) { + this.#logger.warn( + `Received subscription confirmation, but no matching pending subscription found for subscription ID: ${requestId}.`, + ); + return; + } + + await this.#subscriptionRepository.update({ + ...subscription, + status: 'confirmed', + rpcSubscriptionId, + confirmedAt: new Date().toISOString(), + }); + + this.#logger.info( + `Subscription confirmed: request ID: ${requestId} -> RPC ID: ${rpcSubscriptionId}`, + ); + } + + async #handleUnsubscriptionConfirmation( + message: UnsubscriptionConfirmation, + ): Promise { + this.#logger.info(`Received unsubscription confirmation`, message); + } + + /** + * Handles the various types of WebSocket error messages. + * + * 1. Subscription Establishment Errors, when the initial subscription request fails: + * + * ```json + * // We sent: {"jsonrpc":"2.0","id":123,"method":"accountSubscribe","params":[...]} + * // Server responds with error: + * { + * "jsonrpc": "2.0", + * "error": { + * "code": -32602, + * "message": "Invalid params: account not found" + * }, + * "id": 123 // Same ID as our subscription request + * } + * ``` + * This means the subscription was never established → We should clean up from #pendingSubscriptions. + * + * 2. Individual Notification Errors. After a subscription is established, individual notifications might have errors: + * + * ```json + * // Normal notification: + * { + * "jsonrpc": "2.0", + * "method": "accountNotification", + * "params": { + * "subscription": 98765, + * "result": {"error": "Temporary RPC node issue"} + * } + * } + * ``` + * The subscription is still valid → Keep it active, just log the error. + * + * 3. Connection-Level Errors (No specific cleanup) + * ```json + * { + * "jsonrpc": "2.0", + * "error": { + * "code": -32700, + * "message": "Parse error" + * } + * // No "id" field + * } + * ``` + * + * @param response - The response to handle. + */ + async #handleFailure(response: JsonRpcFailure): Promise { + if ('id' in response && response.id !== undefined) { + // This is a response to a specific subscription request, expect the error to be related to a pending subscription. + const subscription = await this.#subscriptionRepository.getById( + String(response.id), + ); + + if (subscription?.status === 'pending') { + // The subscription request failed. We can remove the pending subscription from the repository. + await this.#subscriptionRepository.delete(subscription.id); + + this.#logger.error( + `Subscription establishment failed for ${subscription.id}:`, + response.error, + ); + } else { + // Could be an error response to an unsubscribe request or other operation + this.#logger.error( + `Received error for request ID: ${response.id}`, + response.error, + ); + } + } else { + // Connection-level error - doesn't affect individual subscriptions + this.#logger.error(`Connection-level error:`, response.error); + } + } + + /** + * Generates a deterministic ID for a subscription request. + * This allows for idempotent subscriptions: same request -> same ID. + * It also avoids duplicate subscriptions. + * + * @param request - The subscription request. + * @returns The ID of the subscription. + */ + async #generateId(request: SubscriptionRequest): Promise { + // Create a deterministic hash from the essential request components + const hashInput = { + method: request.method, + params: request.params, + network: request.network, + // Include metadata for uniqueness but normalize it + metadata: request.metadata ?? null, + }; + + // Use deterministic serialization that sorts keys at all levels + const inputString = this.#deterministicStringify(hashInput); + const encoder = new TextEncoder(); + const data = encoder.encode(inputString); + + // Generate SHA-256 hash and return first 32 chars for readability + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return hashHex; + } + + /** + * Recursively sorts object keys at all levels to ensure deterministic serialization. + * This guarantees that identical objects always produce identical JSON strings. + * + * @param obj - The object to stringify deterministically. + * @returns A deterministic JSON string representation. + */ + #deterministicStringify(obj: any): string { + if (obj === null || obj === undefined) { + return JSON.stringify(obj); + } + + if (typeof obj !== 'object') { + return JSON.stringify(obj); + } + + if (Array.isArray(obj)) { + return `[${obj + .map((item) => this.#deterministicStringify(item)) + .join(',')}]`; + } + + // For objects, sort keys and recursively stringify values + const sortedKeys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); + const pairs = sortedKeys.map((key) => { + const value = this.#deterministicStringify(obj[key]); + return `"${key}":${value}`; + }); + + return `{${pairs.join(',')}}`; + } + + async #listSubscriptions(): Promise { + const subscriptions = await this.#subscriptionRepository.getAll(); + this.#logger.info(`Subscriptions`, { + subscriptions, + notificationHandlers: this.#notificationHandlers, + }); + } + + #asRequest(subscription: Subscription): SubscriptionRequest { + return { + method: subscription.method, + params: subscription.params, + network: subscription.network, + ...(subscription.expiryMilliseconds + ? { + expiryMilliseconds: subscription.expiryMilliseconds, + } + : {}), + ...(subscription.metadata ? { metadata: subscription.metadata } : {}), + }; + } + + /** + * Re-subscribes to all subscriptions for the given network. + * + * @param network - The network to re-subscribe to. + */ + async #reSubscribe(network: Network): Promise { + this.#logger.info( + `Re-subscribing to all subscriptions for network ${network}`, + ); + + const subscriptionsThisNetwork = ( + await this.#subscriptionRepository.getAll() + ) + .filter((subscription) => subscription.network === network) + .filter(SubscriptionService.#isNotExpired); + + const ids = subscriptionsThisNetwork.map((subscription) => subscription.id); + await this.#subscriptionRepository.deleteMany(ids); + + await Promise.allSettled( + subscriptionsThisNetwork.map(async (subscription) => { + await this.subscribe(this.#asRequest(subscription)); + }), + ); + } + + async #handleOnInactive(): Promise { + this.#logger.info(`Client became inactive`); + + await this.#removeExpiredSubscriptions(); + } + + async #removeExpiredSubscriptions(): Promise { + this.#logger.info(`Removing expired subscriptions`); + + const subscriptions = await this.#subscriptionRepository.getAll(); + + const expiredSubscriptions = subscriptions.filter( + SubscriptionService.#isExpired, + ); + + await Promise.allSettled( + expiredSubscriptions.map(async (subscription) => { + await this.unsubscribe(subscription.id); + }), + ); + } + + static #isExpired(subscription: Subscription): boolean { + const now = new Date(); + return Boolean( + subscription.expiresAt && new Date(subscription.expiresAt) < now, + ); + } + + static #isNotExpired(subscription: Subscription): boolean { + return !SubscriptionService.#isExpired(subscription); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts new file mode 100644 index 00000000..fa67784d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts @@ -0,0 +1,129 @@ +import { Network } from '../../constants/solana'; +import type { ConfigProvider } from '../config'; +import { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; + +const mockNetwork = Network.Mainnet; +const mockWebSocketUrl = 'ws://localhost:8080'; +const mockConnectionId = 'some-connection-id'; + +describe('WebSocketConnectionRepository', () => { + let repository: WebSocketConnectionRepository; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + mockConfigProvider = { + getNetworkBy: jest.fn().mockReturnValue({ + caip2Id: mockNetwork, + webSocketUrl: mockWebSocketUrl, + }), + } as unknown as ConfigProvider; + + repository = new WebSocketConnectionRepository(mockConfigProvider); + + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + }); + + describe('getAll', () => { + it('returns empty array when there are no connections', async () => { + jest.spyOn(snap, 'request').mockResolvedValue([]); + + const connections = await repository.getAll(); + + expect(connections).toStrictEqual([]); + }); + + it('returns all connections opened in the extension', async () => { + jest + .spyOn(snap, 'request') + .mockResolvedValue([{ id: mockConnectionId, url: mockWebSocketUrl }]); + + const connections = await repository.getAll(); + expect(connections).toStrictEqual([ + { id: mockConnectionId, url: mockWebSocketUrl, network: mockNetwork }, + ]); + }); + }); + + describe('getById', () => { + it('returns null when the connection does not exist', async () => { + jest.spyOn(snap, 'request').mockResolvedValue([]); + + const connection = await repository.getById('1'); + + expect(connection).toBeNull(); + }); + + it('returns the connection when it exists', async () => { + jest + .spyOn(snap, 'request') + .mockResolvedValue([{ id: mockConnectionId, url: mockWebSocketUrl }]); + + const connection = await repository.getById(mockConnectionId); + + expect(connection).toStrictEqual({ + id: mockConnectionId, + url: mockWebSocketUrl, + network: mockNetwork, + }); + }); + }); + + describe('findByUrl', () => { + it('returns null when the connection does not exist', async () => { + jest.spyOn(snap, 'request').mockResolvedValue([]); + + const connection = await repository.findByNetwork(mockNetwork); + + expect(connection).toBeNull(); + }); + + it('returns the connection when it exists', async () => { + jest + .spyOn(snap, 'request') + .mockResolvedValue([{ id: mockConnectionId, url: mockWebSocketUrl }]); + + const connection = await repository.findByNetwork(mockNetwork); + + expect(connection).toStrictEqual({ + id: mockConnectionId, + url: mockWebSocketUrl, + network: mockNetwork, + }); + }); + }); + + describe('save', () => { + it('saves the connection', async () => { + jest.spyOn(snap, 'request').mockResolvedValue(mockConnectionId); + + const connection = await repository.save({ + network: mockNetwork, + url: mockWebSocketUrl, + protocols: [], + }); + + expect(connection).toStrictEqual({ + id: mockConnectionId, + network: mockNetwork, + url: mockWebSocketUrl, + protocols: [], + }); + }); + }); + + describe('delete', () => { + it('deletes the connection', async () => { + jest.spyOn(snap, 'request').mockResolvedValue(null); + + await repository.delete(mockConnectionId); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_closeWebSocket', + params: { id: mockConnectionId }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts new file mode 100644 index 00000000..0b0606de --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts @@ -0,0 +1,120 @@ +import type { WebSocketConnection } from '../../../entities'; +import type { Network } from '../../constants/solana'; +import type { ConfigProvider } from '../config'; + +/** + * Repository that is treating the Snap's WebSocket storage as a persistent data store, where: + * - snap.request('snap_getWebSockets') = SELECT query + * - snap.request('snap_openWebSocket') = INSERT operation + * - snap.request('snap_closeWebSocket') = DELETE operation + * + * It also tracks bidirectional mappings between the connection ID and the URL to perform fast lookups. + */ +export class WebSocketConnectionRepository { + readonly #configProvider: ConfigProvider; + + constructor(configProvider: ConfigProvider) { + this.#configProvider = configProvider; + } + + /** + * Gets all connections. + * + * @returns All connections. + */ + async getAll(): Promise { + const snapConnections = await snap.request({ + method: 'snap_getWebSockets', + }); + + // Enhance the connections with the network + return snapConnections.map((connection) => ({ + ...connection, + network: this.#findNetworkByWebSocketUrl(connection.url), + })); + } + + /** + * Gets the connection for the specified ID. + * + * @param id - The ID of the connection to get. + * @returns The connection, or null if no connection exists for the ID. + */ + async getById(id: string): Promise { + const existingConnections = await this.getAll(); + return ( + existingConnections.find((connection) => connection.id === id) ?? null + ); + } + + /** + * Finds the connection for the specified network. + * + * @param network - The network to find the connection for. + * @returns The connection, or null if no connection exists for the network. + */ + async findByNetwork(network: Network): Promise { + const existingConnections = await this.getAll(); + return ( + existingConnections.find( + (connection) => connection.network === network, + ) ?? null + ); + } + + /** + * Creates a new connection to the specified URL. + * + * @param connection - The connection to create, without the `id` field. + * @returns The connection ID. + */ + async save( + connection: Omit, + ): Promise { + const { url, protocols } = connection; + + const id = await snap.request({ + method: 'snap_openWebSocket', + params: { + url, + ...(protocols ? { protocols } : {}), + }, + }); + + return { + ...connection, + id, + }; + } + + /** + * Closes the connection with the specified ID. + * + * @param id - The ID of the connection to close. + */ + async delete(id: string) { + await snap.request({ + method: 'snap_closeWebSocket', + params: { id }, + }); + } + + /** + * Gets the network for the specified connection ID. + * + * @param webSocketUrl - The WebSocket URL to get the network for. + * @returns The network. + */ + #findNetworkByWebSocketUrl(webSocketUrl: string): Network { + const network = this.#configProvider.getNetworkBy( + 'webSocketUrl', + webSocketUrl, + ); + + if (!network) { + throw new Error(`No network found for WebSocket URL: ${webSocketUrl}`); + } + + return network.caip2Id; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts new file mode 100644 index 00000000..1b24e37b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts @@ -0,0 +1,606 @@ +import type { WebSocketConnection } from '../../../entities'; +import { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { ConfigProvider } from '../config'; +import type { NetworkConfig } from '../config/ConfigProvider'; +import { mockLogger } from '../mocks/logger'; +import { InMemoryState } from '../state/InMemoryState'; +import type { IStateManager } from '../state/IStateManager'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; +import type { UnencryptedStateValue } from '../state/State'; +import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; +import { WebSocketConnectionService } from './WebSocketConnectionService'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +const mockWebSocketUrl = 'wss://some-mock-url.com/ws/v3/some-id'; +const mockConnectionId = 'mock-connection-id'; + +const createMockWebSocketConnection = ( + id = mockConnectionId, + url = mockWebSocketUrl, + network = Network.Mainnet, +): WebSocketConnection => ({ + id, + url, + protocols: [], + network, +}); + +describe('WebSocketConnectionService', () => { + let service: WebSocketConnectionService; + let mockWebSocketConnectionRepository: WebSocketConnectionRepository; + let mockAnalyticsService: AnalyticsService; + let mockConfigProvider: ConfigProvider; + let mockState: IStateManager; + let mockEventEmitter: EventEmitter; + + const mockNetworksConfig = [ + { + caip2Id: Network.Mainnet, + webSocketUrl: mockWebSocketUrl, + }, + { + caip2Id: Network.Devnet, + webSocketUrl: 'wss://some-mock-url2.com/ws/v3/some-id', + }, + { + caip2Id: Network.Testnet, + webSocketUrl: 'wss://some-mock-url3.com/ws/v3/some-id', + }, + { + caip2Id: Network.Localnet, + webSocketUrl: 'wss://some-mock-url4.com/ws/v3/some-id', + }, + ] as NetworkConfig[]; + + beforeEach(() => { + jest.clearAllMocks(); + + (globalThis as any).snap = { + request: jest.fn(), + }; + + mockWebSocketConnectionRepository = { + getAll: jest.fn(), + getById: jest.fn(), + findByNetwork: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + } as unknown as WebSocketConnectionRepository; + + mockAnalyticsService = { + trackEventWebSocketConnectionClosedNotCleanly: jest.fn(), + } as unknown as AnalyticsService; + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + networks: mockNetworksConfig, + subscriptions: { + maxReconnectAttempts: 5, + reconnectDelayMilliseconds: 1, // To speed up the tests + closeConnectionsGracePeriodMilliseconds: 5000, // 5 seconds for testing + }, + }), + getActiveNetworks: jest + .fn() + .mockResolvedValue([Network.Mainnet, Network.Devnet]), + getNetworkBy: jest.fn().mockImplementation((key, value) => { + return mockNetworksConfig.find( + (item) => item[key as keyof NetworkConfig] === value, + ); + }), + } as unknown as ConfigProvider; + + mockState = new InMemoryState(DEFAULT_UNENCRYPTED_STATE); + + mockEventEmitter = new EventEmitter(mockLogger); + + service = new WebSocketConnectionService( + mockWebSocketConnectionRepository, + mockAnalyticsService, + mockConfigProvider, + mockState, + mockEventEmitter, + mockLogger, + ); + }); + + describe('#setupConnections', () => { + it('opens the connections for all active networks when the client is active', async () => { + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet, Network.Devnet]); + + jest.spyOn(snap, 'request').mockResolvedValueOnce({ + active: true, + }); + + // Simulate a client start event + await mockEventEmitter.emitSync('onStart'); + + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(2); + }); + + it('closes the connections for all active networks when the client is inactive', async () => { + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet, Network.Devnet]); + + jest.spyOn(snap, 'request').mockResolvedValueOnce({ + active: false, + }); + + const mockConnection0 = createMockWebSocketConnection( + 'conn1', + 'wss://some-url.com', + Network.Mainnet, + ); + + const mockConnection1 = createMockWebSocketConnection( + 'conn2', + 'wss://other-url.com', + Network.Devnet, + ); + + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValueOnce([mockConnection0, mockConnection1]); + + // Simulate a client start event + await mockEventEmitter.emitSync('onStart'); + + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledTimes(2); + }); + }); + + describe('#openConnectionsForActiveNetworks', () => { + it('opens the connections for all active networks', async () => { + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet, Network.Devnet]); + + jest.spyOn(service, 'openConnection').mockResolvedValueOnce(undefined); + + // Simulate the snap becoming active + await mockEventEmitter.emitSync('onActive'); + + expect(service.openConnection).toHaveBeenCalledTimes(2); + }); + + it('clears existing retry attempts', async () => { + // We register a recovery handler for Mainnet. We expect it to be cleared when the method is executed. + const recoveryHandler = jest.fn(); + service.onConnectionRecovery(Network.Mainnet, recoveryHandler); + + /** + * Setup Mainnet as active network, but it has no connection, so calling openConnectionsForActiveNetworks will: + * - clear the retry attempts + * - open the connection + * - upon opening, we will trigger all recovery handlers + * - but since retry attempts have been cleared, they should not have been called + */ + jest + .spyOn(mockConfigProvider, 'getActiveNetworks') + .mockResolvedValue([Network.Mainnet]); + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValueOnce([]); + jest + .spyOn(mockWebSocketConnectionRepository, 'findByNetwork') + .mockResolvedValueOnce(null); + + // Simulate the snap start event + await mockEventEmitter.emitSync('onStart'); + + // The connection has recovered, but the recovery handler should not have been called because retry attempts were cleared + expect(recoveryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('closeAllConnections', () => { + it('closes all connections when called directly', async () => { + const mockConnections = [ + createMockWebSocketConnection( + 'conn1', + mockWebSocketUrl, + Network.Mainnet, + ), + createMockWebSocketConnection( + 'conn2', + 'wss://other-url.com', + Network.Devnet, + ), + ]; + + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValue(mockConnections); + + // Call closeAllConnections directly + await service.closeAllConnections(); + + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledTimes(2); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledWith( + 'conn1', + ); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledWith( + 'conn2', + ); + }); + + it('continues closing other connections even if one fails', async () => { + const mockConnections = [ + createMockWebSocketConnection( + 'conn1', + mockWebSocketUrl, + Network.Mainnet, + ), + createMockWebSocketConnection( + 'conn2', + 'wss://other-url.com', + Network.Devnet, + ), + createMockWebSocketConnection( + 'conn3', + 'wss://third-url.com', + Network.Testnet, + ), + ]; + + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValue(mockConnections); + + // Mock one connection to fail deletion + jest + .spyOn(mockWebSocketConnectionRepository, 'delete') + .mockRejectedValueOnce(new Error('Failed to delete connection')) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + // Call closeAllConnections directly + await service.closeAllConnections(); + + // Should attempt to delete all connections despite one failing + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledTimes(3); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledWith( + 'conn1', + ); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledWith( + 'conn2', + ); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledWith( + 'conn3', + ); + }); + }); + + describe('openConnection', () => { + it('opens the connection for the network', async () => { + jest + .spyOn(mockWebSocketConnectionRepository, 'findByNetwork') + .mockResolvedValue(null); + + await service.openConnection(Network.Mainnet); + + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(1); + }); + + it('does nothing if the connection already exists', async () => { + jest + .spyOn(mockWebSocketConnectionRepository, 'findByNetwork') + .mockResolvedValue(createMockWebSocketConnection()); + + await service.openConnection(Network.Mainnet); + + expect(mockWebSocketConnectionRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('#handleWebSocketEvent', () => { + describe('when the event is a connect', () => { + beforeEach(async () => { + const mockConnection = createMockWebSocketConnection(); + + jest + .spyOn(mockWebSocketConnectionRepository, 'getById') + .mockResolvedValue(mockConnection); + + jest + .spyOn(mockWebSocketConnectionRepository, 'findByNetwork') + .mockResolvedValueOnce(mockConnection); + }); + + it('triggers all the recovery handlers', async () => { + const recoveryHandler0 = jest.fn(); + const recoveryHandler1 = jest.fn(); + + service.onConnectionRecovery(Network.Mainnet, recoveryHandler0); + service.onConnectionRecovery(Network.Mainnet, recoveryHandler1); + + // Send the connect event + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'open', + }); + + expect(recoveryHandler0).toHaveBeenCalled(); + expect(recoveryHandler1).toHaveBeenCalled(); + }); + }); + + describe('when the event is a disconnect', () => { + beforeEach(async () => { + const mockConnection = createMockWebSocketConnection(); + + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValueOnce([mockConnection]); + + jest + .spyOn(mockWebSocketConnectionRepository, 'getById') + .mockResolvedValueOnce(mockConnection); + }); + + describe('when the connection is closed cleanly', () => { + it('does not attempt to reconnect', async () => { + // Send a clean disconnect event + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: true, + origin: 'wss://some-mock-url.com', + }); + + // Should not attempt to reconnect for clean closures + expect(mockWebSocketConnectionRepository.save).not.toHaveBeenCalled(); + }); + + it('does not track an event', async () => { + // Send a clean disconnect event + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: true, + origin: 'wss://some-mock-url.com', + }); + + expect( + mockAnalyticsService.trackEventWebSocketConnectionClosedNotCleanly, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when the connection is closed not cleanly', () => { + it('attempts to reconnect until it succeeds, up to max attempts', async () => { + // 1st and 2nd calls are the fail attempts, 3rd is the success attempt + jest + .spyOn(mockWebSocketConnectionRepository, 'save') + .mockRejectedValueOnce(new Error('Connection failed')) + .mockRejectedValueOnce(new Error('Connection failed')) + .mockResolvedValueOnce(createMockWebSocketConnection()); + + // Send the initial disconnect event + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + + // The first 2 attemps at reconnecting will fail. Each failure will emit its own disconnect event. + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + // The 3rd attempt at reconnecting will succeed. This will emit a connect event. + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'open', + origin: 'wss://some-mock-url.com', + }); + + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes( + 3, + ); + }); + + it('retries up to the max number of attempts', async () => { + jest + .spyOn(mockWebSocketConnectionRepository, 'save') + .mockRejectedValue(new Error('Connection failed')); + + // Send the initial disconnect event + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + + // Send many disconnect events, more than the max number of attempts + for (let i = 0; i < 10; i++) { + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + } + + // Check that we do not retry more than the max + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes( + 5, + ); + }); + + it('tracks an event', async () => { + await mockEventEmitter.emitSync('onWebSocketEvent', { + id: mockConnectionId, + type: 'close', + wasClean: false, + origin: 'wss://some-mock-url.com', + }); + + expect( + mockAnalyticsService.trackEventWebSocketConnectionClosedNotCleanly, + ).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('findByNetwork', () => { + it('returns the connection ID for the network', async () => { + const mockConnection = createMockWebSocketConnection(); + jest + .spyOn(mockWebSocketConnectionRepository, 'findByNetwork') + .mockResolvedValueOnce(mockConnection); + + const connection = await service.findByNetwork(Network.Mainnet); + + expect(connection).toStrictEqual(mockConnection); + }); + }); + + describe('background event scheduling', () => { + describe('when client becomes inactive', () => { + it('schedules a background event to close connections after grace period', async () => { + const mockEventId = 'test-event-id-123'; + jest.spyOn(snap, 'request').mockResolvedValueOnce(mockEventId); + jest.spyOn(mockState, 'setKey').mockResolvedValueOnce(undefined); + + await mockEventEmitter.emitSync('onInactive'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT5S', // 5 seconds based on test config + request: { method: 'closeWebSocketConnections' }, + }, + }); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'webSocketConnections.closeWebSocketConnectionsBackgroundEventId', + mockEventId, + ); + }); + + it('cancels existing background event before scheduling new one', async () => { + const existingEventId = 'existing-event-id'; + const newEventId = 'new-event-id'; + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(existingEventId); + jest + .spyOn(snap, 'request') + .mockResolvedValueOnce(null) // cancel call + .mockResolvedValueOnce(newEventId); // schedule call + jest.spyOn(mockState, 'setKey').mockResolvedValue(undefined); + + await mockEventEmitter.emitSync('onInactive'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_cancelBackgroundEvent', + params: { id: existingEventId }, + }); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'webSocketConnections.closeWebSocketConnectionsBackgroundEventId', + null, + ); + }); + + it('handles cancellation errors gracefully', async () => { + const existingEventId = 'existing-event-id'; + const newEventId = 'new-event-id'; + const error = new Error('Cancel failed'); + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(existingEventId); + jest + .spyOn(snap, 'request') + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(newEventId); + jest.spyOn(mockState, 'setKey').mockResolvedValue(undefined); + + await mockEventEmitter.emitSync('onInactive'); + + // Should still schedule new event despite cancellation failure + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT5S', + request: { method: 'closeWebSocketConnections' }, + }, + }); + expect(trackError).toHaveBeenCalledWith(error); + }); + }); + + describe('when client becomes active', () => { + it('cancels existing background event and opens connections', async () => { + const existingEventId = 'existing-event-id'; + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(existingEventId); + jest.spyOn(snap, 'request').mockResolvedValueOnce(null); + jest.spyOn(mockState, 'setKey').mockResolvedValueOnce(undefined); + jest.spyOn(service, 'openConnection').mockResolvedValue(undefined); + + await mockEventEmitter.emitSync('onActive'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_cancelBackgroundEvent', + params: { id: existingEventId }, + }); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'webSocketConnections.closeWebSocketConnectionsBackgroundEventId', + null, + ); + + expect(service.openConnection).toHaveBeenCalledTimes(2); // For Mainnet and Devnet + }); + + it('handles missing background event ID gracefully', async () => { + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(null); + jest.spyOn(service, 'openConnection').mockResolvedValue(undefined); + + await mockEventEmitter.emitSync('onActive'); + + expect(snap.request).not.toHaveBeenCalledWith({ + method: 'snap_cancelBackgroundEvent', + params: expect.anything(), + }); + + expect(service.openConnection).toHaveBeenCalledTimes(2); + }); + + it('continues opening connections even if cancellation fails', async () => { + const existingEventId = 'existing-event-id'; + + jest.spyOn(mockState, 'getKey').mockResolvedValueOnce(existingEventId); + jest + .spyOn(snap, 'request') + .mockRejectedValueOnce(new Error('Cancel failed')); + jest.spyOn(mockState, 'setKey').mockResolvedValueOnce(undefined); + jest.spyOn(service, 'openConnection').mockResolvedValue(undefined); + + await mockEventEmitter.emitSync('onActive'); + + expect(service.openConnection).toHaveBeenCalledTimes(2); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts new file mode 100644 index 00000000..acd943d1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts @@ -0,0 +1,453 @@ +import type { + WebSocketCloseEvent, + WebSocketEvent, + WebSocketOpenEvent, +} from '@metamask/snaps-sdk'; + +import type { + ConnectionRecoveryHandler, + WebSocketConnection, +} from '../../../entities'; +import type { EventEmitter } from '../../../infrastructure'; +import type { Network } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import { getClientStatus } from '../../utils/interface'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { ConfigProvider } from '../config'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; +import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; + +/** + * Manages WebSocket connections for different Solana networks, providing robust connection + * lifecycle management with automatic retry logic, reconnection handling, and connection + * state tracking. + * + * Key Features: + * - Maintains a mapping between Solana networks and their corresponding WebSocket connection IDs + * - Implements exponential backoff strategy for failed connections with configurable maximum retry attempts + * - Automatically handles disconnections and attempts reconnection with proper cleanup of stale connection mappings + * - Processes WebSocket connection events (connect, disconnect, error) and triggers appropriate recovery mechanisms + * - Converts HTTP RPC URLs to WebSocket URLs for subscription endpoints + */ +export class WebSocketConnectionService { + readonly #connectionRepository: WebSocketConnectionRepository; + + readonly #analyticsService: AnalyticsService; + + readonly #configProvider: ConfigProvider; + + readonly #state: IStateManager; + + readonly #eventEmitter: EventEmitter; + + readonly #logger: ILogger; + + readonly #maxReconnectAttempts: number; + + readonly #reconnectDelayMilliseconds: number; + + readonly #connectionRecoveryHandlers: Map< + Network, + ConnectionRecoveryHandler[] + > = new Map(); + + readonly #retryAttempts: Map = new Map(); + + readonly #closeConnectionsGracePeriodMilliseconds: number; + + readonly #stateKey = 'webSocketConnections'; + + constructor( + connectionRepository: WebSocketConnectionRepository, + analyticsService: AnalyticsService, + configProvider: ConfigProvider, + state: IStateManager, + eventEmitter: EventEmitter, + logger: ILogger, + ) { + const { + maxReconnectAttempts, + reconnectDelayMilliseconds, + closeConnectionsGracePeriodMilliseconds, + } = configProvider.get().subscriptions; + + this.#connectionRepository = connectionRepository; + this.#analyticsService = analyticsService; + this.#configProvider = configProvider; + this.#state = state; + this.#eventEmitter = eventEmitter; + this.#logger = createPrefixedLogger( + logger, + '[🔌 WebSocketConnectionService]', + ); + this.#maxReconnectAttempts = maxReconnectAttempts; + this.#reconnectDelayMilliseconds = reconnectDelayMilliseconds; + this.#closeConnectionsGracePeriodMilliseconds = + closeConnectionsGracePeriodMilliseconds; + + this.#bindHandlers(); + } + + #bindHandlers(): void { + // Aliases to make the code more readable + const setup = this.#setupConnections.bind(this); + const handleOnActive = this.#handleOnActive.bind(this); + const handleOnInactive = this.#handleOnInactive.bind(this); + const list = this.#listConnections.bind(this); + const handleEvent = this.#handleWebSocketEvent.bind(this); + + // When the extension starts, or that the snap is updated / installed, the Snap platform looses previously opened web socket connections, + // so we need to setup them up again, if needed. + this.#eventEmitter.on('onStart', setup); + this.#eventEmitter.on('onUpdate', setup); + this.#eventEmitter.on('onInstall', setup); + + // When the extension becomes active, we open all connections + this.#eventEmitter.on('onActive', handleOnActive); + + // When the extension becomes inactive, we schedule a job to close all connections + this.#eventEmitter.on('onInactive', handleOnInactive); + + this.#eventEmitter.on('onWebSocketEvent', handleEvent); + + // Specific bind to enable manual testing from the test dapp + this.#eventEmitter.on('onListWebSockets', list); + } + + /** + * Sets up the connections. + * - If the client is active, we open them for all active networks. + * - If the client is inactive, we close them all. + */ + async #setupConnections(): Promise { + this.#logger.log(`Setting up connections`); + + const { active } = await getClientStatus(); + + if (active) { + await this.#openConnectionsForActiveNetworks(); + } else { + await this.closeAllConnections(); + } + } + + async #openConnectionsForActiveNetworks(): Promise { + this.#logger.log(`Opening connections for active networks`); + + const activeNetworks = await this.#configProvider.getActiveNetworks(); + + this.#retryAttempts.clear(); + + // Open the connections for the active networks + await Promise.allSettled( + activeNetworks.map(async (network) => { + await this.openConnection(network); + }), + ); + } + + /** + * Idempotently opens a WebSocket connection for the given network. + * If a connection already exists for the network, this method does nothing. + * + * @param network - The network for which to open a connection. + * @returns A promise that resolves when the connection is established or already exists. + */ + async openConnection(network: Network): Promise { + this.#logger.log(`Opening connection for network ${network}`); + + // Get the websocket url + const networkConfig = this.#configProvider.getNetworkBy('caip2Id', network); + const { webSocketUrl } = networkConfig; + + // Check if a connection already exists for this network + const existingConnection = + await this.#connectionRepository.findByNetwork(network); + + if (existingConnection) { + this.#logger.log(`✅ Connection for network ${network} already exists`); + return; + } + + await this.#connectionRepository.save({ + network, + url: webSocketUrl, + protocols: [], + }); + } + + async closeAllConnections(): Promise { + this.#logger.log(`Closing all connections`); + + const connections = await this.#connectionRepository.getAll(); + await Promise.allSettled(connections.map(this.#closeConnection.bind(this))); + + // Clear the state + await this.#state.setKey( + `${this.#stateKey}.closeWebSocketConnectionsBackgroundEventId`, + null, + ); + } + + async #closeConnection(connection: WebSocketConnection): Promise { + this.#logger.log(`Closing connection for network ${connection.network}`); + + await this.#connectionRepository.delete(connection.id); + } + + /** + * Registers a handler to be called when connection is recovered. + * + * @param network - The network to register the handler for. + * @param handler - The handler function to register. + */ + onConnectionRecovery( + network: Network, + handler: ConnectionRecoveryHandler, + ): void { + const existingHandlers = + this.#connectionRecoveryHandlers.get(network) ?? []; + + this.#connectionRecoveryHandlers.set(network, [ + ...existingHandlers, + handler, + ]); + } + + /** + * Gets the connection ID for the specified network. + * + * @param network - The network to get the connection ID for. + * @returns The connection ID, or null if no connection exists for the network. + */ + async findByNetwork(network: Network): Promise { + const connection = await this.#connectionRepository.findByNetwork(network); + return connection ?? null; + } + + /** + * Gets the connection for the specified ID. + * + * @param id - The ID of the connection to get. + * @returns The connection, or null if no connection exists for the ID. + */ + async findById(id: string): Promise { + return this.#connectionRepository.getById(id); + } + + async #handleWebSocketEvent(event: WebSocketEvent): Promise { + // We only care about open and close events, that inform us about connection lifecycle + if (event.type !== 'open' && event.type !== 'close') { + return; + } + + const { id: connectionId, type } = event; + + this.#logger.log( + `Handling connection event "${type}" for ${connectionId}`, + event, + ); + + switch (type) { + case 'open': + await this.#handleConnected(event); + break; + case 'close': + await this.#handleDisconnected(event); + break; + default: + this.#logger.warn(`Unknown connection event type: ${type}`); + } + } + + async #handleConnected(event: WebSocketOpenEvent): Promise { + const { id: connectionId } = event; + const connection = await this.#connectionRepository.getById(connectionId); + + if (!connection) { + this.#logger.warn(`No connection found with id: ${connectionId}`); + return; + } + + this.#logger.log(`✅ Connected to ${connectionId}`, event); + + const { network } = connection; + + // Reset retry attempts on successful connection + this.#retryAttempts.delete(network); + + const handlers = this.#connectionRecoveryHandlers.get(network) ?? []; + + this.#logger.log( + `Triggering ${handlers.length} connection recovery handlers`, + network, + ); + + // Trigger all recovery handlers + const recoveryPromises = + handlers.map(async (handler) => { + try { + await handler(network); + } catch (error) { + this.#logger.error(`Error in connection recovery handler`, error); + } + }) ?? []; + + await Promise.allSettled(recoveryPromises); + } + + async #handleDisconnected(event: WebSocketCloseEvent): Promise { + const { wasClean, origin, code, reason } = event; + + // If the connection was closed cleanly (= we closed it intentionally), we don't need to attempt to reconnect + if (wasClean) { + this.#logger.log(`✅ Connection closed cleanly`, event); + return; + } + + // Here, we cannot rely on this.#connectionRepository.getById() because the connection doesn't exist anymore, + // so we need to find the network from the event origin + const { networks } = this.#configProvider.get(); + const network = networks.find((item) => + item.webSocketUrl.startsWith(origin), + ); + + if (!network) { + this.#logger.warn(`No network found for origin`, origin); + return; + } + + // Track an event + await this.#analyticsService.trackEventWebSocketConnectionClosedNotCleanly( + origin, + code, + reason, + ); + + await this.#attemptReconnect(network.caip2Id); + } + + async #attemptReconnect(network: Network): Promise { + const currentAttempts = this.#retryAttempts.get(network) ?? 0; + const nextAttempt = currentAttempts + 1; + this.#retryAttempts.set(network, nextAttempt); + + if (nextAttempt > this.#maxReconnectAttempts) { + this.#logger.error( + `❌ Failed to reconnect to ${network} after ${this.#maxReconnectAttempts}/${this.#maxReconnectAttempts} attempts. Giving up.`, + ); + return; + } + + const delay = + this.#reconnectDelayMilliseconds * Math.pow(2, currentAttempts); + this.#logger.warn( + `❌ Disconnected from ${network}. Will try reconnecting in ${delay}ms (attempt ${nextAttempt}/${this.#maxReconnectAttempts})`, + ); + + await new Promise((resolve) => { + setTimeout(() => { + this.openConnection(network) + .catch((error) => { + this.#logger.info(`Error opening connection for ${network}`, error); + // Do nothing else here. If the connection fails to open, we receive a disconnect event, + // that will be handled by the #handleDisconnected method. + }) + .finally(() => resolve()); + }, delay); + }); + } + + async #listConnections(): Promise { + const connections = await this.#connectionRepository.getAll(); + + this.#logger.log(`All connections`, { + connections, + connectionRecoveryHandlers: this.#connectionRecoveryHandlers, + }); + } + + async #handleOnActive(): Promise { + this.#logger.log(`Client became active`); + + // Cancel any existing close connections background event + await this.#cancelCloseConnectionsBackgroundEvent(); + + // Open the connections + await this.#openConnectionsForActiveNetworks(); + } + + async #handleOnInactive(): Promise { + this.#logger.log(`Client became inactive`); + + // Cancel any existing close connections background event + await this.#cancelCloseConnectionsBackgroundEvent(); + + const gracePeriodSeconds = Math.floor( + this.#closeConnectionsGracePeriodMilliseconds / 1000, + ); + + // Schedule a background event that will close the connections after the grace period + const id = await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: `PT${gracePeriodSeconds}S`, + request: { method: 'closeWebSocketConnections' }, + }, + }); + + /** + * Store the event ID in the state so that we can cancel it if the extension becomes active again. + * It needs to be stored in persistent state so that it survives snap restarts. + */ + await this.#state.setKey( + `${this.#stateKey}.closeWebSocketConnectionsBackgroundEventId`, + id, + ); + + this.#logger.log( + `Scheduled background event to close connections after ${gracePeriodSeconds}s`, + id, + ); + } + + /** + * Cancels the background event that will close connections if it exists. + * Errors are caught so that cancellation failure doesn't block other operations. + */ + async #cancelCloseConnectionsBackgroundEvent(): Promise { + try { + const eventId = await this.#state.getKey( + `${this.#stateKey}.closeWebSocketConnectionsBackgroundEventId`, + ); + + if (!eventId) { + return; + } + + await snap.request({ + method: 'snap_cancelBackgroundEvent', + params: { + id: eventId, + }, + }); + + this.#logger.log( + `🫸 Cancelled background event to close connections`, + eventId, + ); + } catch (error) { + await trackError(error); + this.#logger.warn(`Failed to cancel background event`, error); + } finally { + // Clear the state + await this.#state.setKey( + `${this.#stateKey}.closeWebSocketConnectionsBackgroundEventId`, + null, + ); + } + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/index.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/index.ts new file mode 100644 index 00000000..77f43c01 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/index.ts @@ -0,0 +1,7 @@ +export * from './KeyringAccountMonitor'; +export * from './MonitoredAccountsInitializer'; +export * from './SignatureMonitor'; +export * from './SubscriptionRepository'; +export * from './SubscriptionService'; +export * from './WebSocketConnectionRepository'; +export * from './WebSocketConnectionService'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/subscriptions/parseWebSocketMessage.ts b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/parseWebSocketMessage.ts new file mode 100644 index 00000000..18d2f6c0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/subscriptions/parseWebSocketMessage.ts @@ -0,0 +1,44 @@ +import type { WebSocketMessage } from '@metamask/snaps-sdk'; + +/** + * A message that we receive from the RPC WebSocket server after a subscription is confirmed. + * It contains the notification data we subscribed to. + */ +export type JsonRpcWebSocketMessage = { + jsonrpc: string; + method: string; + params: { + subscription: number; + result: TNotification; + }; +}; + +export const parseWebSocketMessage = ( + event: WebSocketMessage, +): JsonRpcWebSocketMessage => { + const { data } = event; + let jsonParsed: any; + + // Handle SIP-20 message format + if (data && typeof data === 'object' && 'type' in data) { + // This is already a SIP-20 formatted message data + if (data.type === 'text') { + jsonParsed = + typeof data.message === 'string' + ? JSON.parse(data.message) + : data.message; + } else if (data.type === 'binary') { + // Convert binary message to string and parse + const binaryArray = data.message; + const messageString = String.fromCharCode(...binaryArray); + jsonParsed = JSON.parse(messageString); + } else { + throw new Error('Unknown message data'); + } + } else { + // Fallback for direct message parsing + jsonParsed = typeof data === 'string' ? JSON.parse(data) : data; + } + + return jsonParsed; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.test.ts b/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.test.ts new file mode 100644 index 00000000..37491e96 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.test.ts @@ -0,0 +1,523 @@ +import { Duration } from '@metamask/utils'; + +import { MOCK_HISTORICAL_PRICES } from '../../clients/price-api/mocks/historical-prices'; +import { MOCK_SPOT_PRICES } from '../../clients/price-api/mocks/spot-prices'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { SpotPrice } from '../../clients/price-api/types'; +import { MOCK_EXCHANGE_RATES } from '../../test/mocks/price-api/exchange-rates'; +import { trackError } from '../../utils/errors'; +import { ConfigProvider } from '../config'; +import { mockLogger } from '../mocks/logger'; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { TokenPricesService } from './TokenPrices'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('TokenPricesService', () => { + /* Crypto */ + const BTC = 'bip122:000000000019d6689c085ae165831e93/slip44:0'; + const ETH = 'eip155:1/slip44:60'; + const SOL = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; + const USDC = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + + /* Fiat */ + const EUR = 'swift:0/iso4217:EUR'; + const USD = 'swift:0/iso4217:USD'; + const BZR = 'swift:0/iso4217:BRL'; + + const UNKNOWN_CRYPTO_1 = 'unknown:1/slip44:1'; + const UNKNOWN_CRYPTO_2 = 'unknown:2/slip44:2'; + const UNKNOWN_FIAT_1 = 'swift:0/iso4217:AAA'; + const UNKNOWN_FIAT_2 = 'swift:0/iso4217:ZZZ'; + + let tokenPricesService: TokenPricesService; + let mockPriceApiClient: PriceApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + mockPriceApiClient = { + getFiatExchangeRates: jest.fn().mockResolvedValue(MOCK_EXCHANGE_RATES), + getMultipleSpotPrices: jest.fn().mockResolvedValue(MOCK_SPOT_PRICES), + getHistoricalPrices: jest.fn().mockResolvedValue(MOCK_HISTORICAL_PRICES), + cacheTtlsMilliseconds: { + historicalPrices: Duration.Hour, + spotPrices: Duration.Hour, + }, + } as unknown as PriceApiClient; + + mockConfigProvider = new ConfigProvider(); + + tokenPricesService = new TokenPricesService({ + priceApiClient: mockPriceApiClient, + configProvider: mockConfigProvider, + logger: mockLogger, + }); + }); + + describe('getMultipleTokenConversions', () => { + it('returns empty object when no conversions provided', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([]); + expect(result).toStrictEqual({}); + }); + + describe('when includeMarketData is false', () => { + it('handles fiat to fiat conversions', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([ + /* Same currency */ + { from: USD, to: USD }, + { from: EUR, to: EUR }, + /* Different currency */ + { from: EUR, to: USD }, + { from: USD, to: BZR }, + { from: EUR, to: BZR }, + ]); + + expect(result).toStrictEqual( + expect.objectContaining({ + [USD]: expect.objectContaining({ + [USD]: { + rate: '1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [BZR]: { + rate: '5.44630000241062899996', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + [EUR]: expect.objectContaining({ + [EUR]: { + rate: '1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [USD]: { + rate: '1.17696630204744878672', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [BZR]: { + rate: '6.41011157367824942681', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + }), + ); + }); + + it('handles crypto to crypto conversions', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([ + /* Same currency */ + { from: BTC, to: BTC }, + { from: ETH, to: ETH }, + /* Different currency */ + { from: BTC, to: ETH }, + { from: ETH, to: SOL }, + { from: SOL, to: USDC }, + ]); + + expect(result).toStrictEqual( + expect.objectContaining({ + [BTC]: expect.objectContaining({ + [BTC]: { + rate: '1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [ETH]: { + rate: '44.96458169857359389595', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + [ETH]: expect.objectContaining({ + [ETH]: { + rate: '1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [SOL]: { + rate: '14.69103829451243642206', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + [SOL]: expect.objectContaining({ + [USDC]: { + rate: '126.65075990455942506931', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + }), + ); + }); + + it('handles crypto to fiat conversions', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([ + { from: BTC, to: USD }, + { from: ETH, to: USD }, + { from: SOL, to: USD }, + ]); + + expect(result).toStrictEqual( + expect.objectContaining({ + [BTC]: expect.objectContaining({ + [USD]: { + rate: '77556.84849999227', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + [ETH]: expect.objectContaining({ + [USD]: { + rate: '1724.8431002851428', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + [SOL]: expect.objectContaining({ + [USD]: { + rate: '117.40784182214172', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + }), + ); + }); + + it('handles fiat to crypto conversions', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([ + { from: USD, to: BTC }, + { from: USD, to: ETH }, + { from: USD, to: SOL }, + ]); + + expect(result).toStrictEqual( + expect.objectContaining({ + [USD]: expect.objectContaining({ + [BTC]: { + rate: '0.00001289376785339724', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [ETH]: { + rate: '0.00057976287804652191', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [SOL]: { + rate: '0.00851731864311819686', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }), + }), + ); + }); + + it('handles missing data correctly', async () => { + const result = await tokenPricesService.getMultipleTokenConversions([ + { from: UNKNOWN_CRYPTO_1, to: UNKNOWN_CRYPTO_2 }, + { from: UNKNOWN_CRYPTO_1, to: UNKNOWN_FIAT_1 }, + { from: UNKNOWN_FIAT_1, to: UNKNOWN_CRYPTO_2 }, + { from: UNKNOWN_FIAT_1, to: UNKNOWN_FIAT_2 }, + ]); + + expect(result).toStrictEqual({ + [UNKNOWN_CRYPTO_1]: { + [UNKNOWN_CRYPTO_2]: null, + [UNKNOWN_FIAT_1]: null, + }, + [UNKNOWN_FIAT_1]: { + [UNKNOWN_CRYPTO_2]: null, + [UNKNOWN_FIAT_2]: null, + }, + }); + }); + }); + }); + + describe('getMultipleTokensMarketData', () => { + it('returns empty object when no assets provided', async () => { + const result = await tokenPricesService.getMultipleTokensMarketData([]); + expect(result).toStrictEqual({}); + }); + + it('returns market data in the correct nested structure with asset-to-unit conversions and correct values', async () => { + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: BTC, unit: USD }, + { asset: ETH, unit: USD }, + { asset: SOL, unit: USD }, + { asset: SOL, unit: BTC }, + ]); + + // BTC/USD - actual values from consistent mocks + expect(result[BTC]![USD]).toStrictEqual({ + fungible: true, + marketCap: '1540421085883.0198', + totalVolume: '23748436299.895576', + circulatingSupply: '19844921', + allTimeHigh: '100847.44951017378', + allTimeLow: '62.86163248290115', + pricePercentChange: { + PT1H: -0.4456714429821922, + P1D: 1.3725526422881404, + P7D: -4.2914380354332256, + P14D: 1.3530761284206316, + P30D: -2.6647248645353425, + P200D: 44.69565022141291, + P1Y: 20.367003699380124, + }, + }); + + // ETH/USD - actual values from consistent mocks + expect(result[ETH]![USD]).toStrictEqual({ + fungible: true, + marketCap: '208326525244.77222', + totalVolume: '14672129201.423573', + circulatingSupply: '120659504.7581715', + allTimeHigh: '4522.273813243435', + allTimeLow: '0.4013827867691204', + pricePercentChange: { + PT1H: -0.16193070976498064, + P1D: 1.9964598342126199, + P7D: -10.123102834312476, + P14D: -1.7452971064771636, + P30D: -16.78602306244949, + P200D: -21.026646670919543, + P1Y: -47.45246230239663, + }, + }); + + // SOL/USD - actual values from consistent mocks + expect(result[SOL]![USD]).toStrictEqual({ + fungible: true, + marketCap: '60217502031.67665', + totalVolume: '3389485617.517553', + circulatingSupply: '512506275.4700137', + allTimeHigh: '271.90599356377726', + allTimeLow: '0.46425554356391946', + pricePercentChange: { + PT1H: -0.7015657267954617, + P1D: 1.6270441732346845, + P7D: -10.985589910714582, + P14D: 2.557473792001135, + P30D: -11.519171371325216, + P200D: -4.453777067234332, + P1Y: -35.331458644625535, + }, + }); + + // SOL/BTC - actual converted values from consistent mocks + expect(result[SOL]![BTC]).toStrictEqual({ + fungible: true, + marketCap: '776430.49190791515732749827', + totalVolume: '43703.24069470010538206139', + circulatingSupply: '512506275.4700137', + allTimeHigh: '0.00350589275895866708', + allTimeLow: '0.00000598600320336592', + pricePercentChange: { + PT1H: -0.7015657267954617, + P1D: 1.6270441732346845, + P7D: -10.985589910714582, + P14D: 2.557473792001135, + P30D: -11.519171371325216, + P200D: -4.453777067234332, + P1Y: -35.331458644625535, + }, + }); + }); + + it('only includes price percent change if Price API returns it', async () => { + jest + .spyOn(mockPriceApiClient, 'getMultipleSpotPrices') + .mockResolvedValue({ + [BTC]: { + ...MOCK_SPOT_PRICES[BTC], + pricePercentChange1h: -0.4456714429821922, + pricePercentChange1d: null, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + } as SpotPrice, + }); + + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: BTC, unit: USD }, + ]); + + expect(result[BTC]?.[USD]?.pricePercentChange).toStrictEqual({ + PT1H: -0.4456714429821922, + }); + }); + + it('does not include price percent change field if Price API does not return any values', async () => { + jest + .spyOn(mockPriceApiClient, 'getMultipleSpotPrices') + .mockResolvedValue({ + [BTC]: { + ...MOCK_SPOT_PRICES[BTC], + pricePercentChange1h: null, + pricePercentChange1d: null, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + } as SpotPrice, + }); + + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: BTC, unit: USD }, + ]); + + expect(result[BTC]?.[USD]?.pricePercentChange).toBeUndefined(); + }); + + it('handles missing asset data correctly by skipping those assets', async () => { + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: UNKNOWN_CRYPTO_1, unit: USD }, + { asset: BTC, unit: USD }, + { asset: UNKNOWN_CRYPTO_2, unit: EUR }, + ]); + + // Should only include BTC since UNKNOWN_CRYPTO_1 and UNKNOWN_CRYPTO_2 don't have price data + expect(result).toStrictEqual({ + [BTC]: { + [USD]: { + fungible: true, + marketCap: '1540421085883.0198', + totalVolume: '23748436299.895576', + circulatingSupply: '19844921', + allTimeHigh: '100847.44951017378', + allTimeLow: '62.86163248290115', + pricePercentChange: { + PT1H: -0.4456714429821922, + P1D: 1.3725526422881404, + P7D: -4.2914380354332256, + P14D: 1.3530761284206316, + P30D: -2.6647248645353425, + P200D: 44.69565022141291, + P1Y: 20.367003699380124, + }, + }, + }, + }); + }); + + it('handles missing unit data correctly by skipping those conversions', async () => { + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: BTC, unit: UNKNOWN_FIAT_1 }, + { asset: BTC, unit: USD }, + { asset: ETH, unit: UNKNOWN_FIAT_2 }, + ]); + + // Should only include BTC->USD since UNKNOWN_FIAT_1 and UNKNOWN_FIAT_2 don't have exchange rates + expect(result).toStrictEqual({ + [BTC]: { + [USD]: { + fungible: true, + marketCap: '1540421085883.0198', + totalVolume: '23748436299.895576', + circulatingSupply: '19844921', + allTimeHigh: '100847.44951017378', + allTimeLow: '62.86163248290115', + pricePercentChange: { + PT1H: -0.4456714429821922, + P1D: 1.3725526422881404, + P7D: -4.2914380354332256, + P14D: 1.3530761284206316, + P30D: -2.6647248645353425, + P200D: 44.69565022141291, + P1Y: 20.367003699380124, + }, + }, + }, + }); + }); + + it('handles zero unit rates correctly by skipping those conversions', async () => { + jest + .spyOn(mockPriceApiClient, 'getMultipleSpotPrices') + .mockResolvedValue({ + [BTC]: { + ...MOCK_SPOT_PRICES[BTC]!, + price: 0, // Zero price for unit + }, + [ETH]: MOCK_SPOT_PRICES[ETH]!, + }); + + const result = await tokenPricesService.getMultipleTokensMarketData([ + { asset: ETH, unit: BTC }, // BTC has zero price, so this should be skipped + { asset: ETH, unit: USD }, + ]); + + // Should only include ETH->USD since BTC has zero price + expect(result).toStrictEqual({ + [ETH]: { + [USD]: { + fungible: true, + marketCap: '208326525244.77222', + totalVolume: '14672129201.423573', + circulatingSupply: '120659504.7581715', + allTimeHigh: '4522.273813243435', + allTimeLow: '0.4013827867691204', + pricePercentChange: { + PT1H: -0.16193070976498064, + P1D: 1.9964598342126199, + P7D: -10.123102834312476, + P14D: -1.7452971064771636, + P30D: -16.78602306244949, + P200D: -21.026646670919543, + P1Y: -47.45246230239663, + }, + }, + }, + }); + }); + }); + + describe('getHistoricalPrice', () => { + it('returns historical prices for a token', async () => { + const result = await tokenPricesService.getHistoricalPrice(BTC, USD); + // We use the same prices for all time periods for simplicity + const expectedPrices = MOCK_HISTORICAL_PRICES.prices.map((price) => [ + price[0], + price[1]!.toString(), + ]); + + expect(result).toStrictEqual({ + intervals: { + P1D: expectedPrices, + P7D: expectedPrices, + P1M: expectedPrices, + P3M: expectedPrices, + P1Y: expectedPrices, + P1000Y: expectedPrices, + }, + updateTime: expect.any(Number), + expirationTime: expect.any(Number), + }); + }); + + it('tracks historical price fetch failures', async () => { + const error = new Error('History failed'); + + jest + .spyOn(mockPriceApiClient, 'getHistoricalPrices') + .mockRejectedValueOnce(error); + + await tokenPricesService.getHistoricalPrice(BTC, USD); + + expect(trackError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.ts b/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.ts new file mode 100644 index 00000000..a43efd90 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.ts @@ -0,0 +1,414 @@ +import { CaipAssetTypeStruct } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/keyring-api'; +import type { + AssetConversion, + FungibleAssetMarketData, + HistoricalPriceIntervals, +} from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; +import BigNumber from 'bignumber.js'; +import { pick } from 'lodash'; + +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; +import type { SpotPrice } from '../../clients/price-api/types'; +import type { FiatTicker } from '../../clients/price-api/types'; +import { trackError } from '../../utils/errors'; +import { isFiat } from '../../utils/isFiat'; +import type { ILogger } from '../../utils/logger'; +import type { ConfigProvider } from '../config'; +import type { HistoricalPrice } from './types'; + +export class TokenPricesService { + readonly #priceApiClient: PriceApiClient; + + readonly #logger: ILogger; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + + constructor({ + configProvider, + priceApiClient, + logger, + }: { + configProvider: ConfigProvider; + priceApiClient: PriceApiClient; + logger: ILogger; + }) { + this.#priceApiClient = priceApiClient; + this.#logger = logger; + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + } + + /** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. + * + * @param caipAssetType - The CAIP-19 asset type. + * @returns The fiat ticker. + */ + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; + }> { + const cryptoAssets = allAssets.filter((asset) => !isFiat(asset)); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ + async getMultipleTokenConversions( + conversions: { from: CaipAssetType; to: CaipAssetType }[], + ): Promise< + Record> + > { + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + if (!result[from]) { + result[from] = {}; + } + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; + } + + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ) => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; + } + + async getMultipleTokensMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + if (!result[assetType]) { + result[assetType] = {}; + } + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } + + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch(async (error) => { + await trackError(error); + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result: HistoricalPrice = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/token-prices/types.ts b/merged-packages/solana-wallet-snap/src/core/services/token-prices/types.ts new file mode 100644 index 00000000..fc2e64a2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/token-prices/types.ts @@ -0,0 +1,4 @@ +import type { OnAssetHistoricalPriceResponse } from '@metamask/snaps-sdk'; + +export type HistoricalPrice = + NonNullable['historicalPrice']; diff --git a/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.test.ts b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.test.ts new file mode 100644 index 00000000..b33e83a0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.test.ts @@ -0,0 +1,317 @@ +import type { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; +import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/types'; +import { Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import { trackError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import { TransactionScanService } from './TransactionScan'; +import { ScanStatus, SecurityAlertResponse } from './types'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('TransactionScan', () => { + let transactionScanService: TransactionScanService; + let mockSecurityAlertsApiClient: SecurityAlertsApiClient; + let mockLogger: ILogger; + let mockAnalyticsService: AnalyticsService; + + beforeEach(() => { + mockSecurityAlertsApiClient = { + scanTransactions: jest.fn().mockResolvedValue({}), + } as unknown as SecurityAlertsApiClient; + + mockLogger = { + error: jest.fn(), + } as unknown as ILogger; + + mockAnalyticsService = { + trackEventSecurityScanCompleted: jest.fn().mockResolvedValue(undefined), + trackEventSecurityAlertDetected: jest.fn().mockResolvedValue(undefined), + } as unknown as AnalyticsService; + + transactionScanService = new TransactionScanService( + mockSecurityAlertsApiClient, + mockAnalyticsService, + mockLogger, + ); + }); + + describe('scanTransaction', () => { + it('scans a transaction', async () => { + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockResolvedValue({ + status: 'SUCCESS', + } as SecurityAlertSimulationValidationResponse); + + const result = await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + }); + + expect(result).toMatchObject({ + status: 'SUCCESS', + }); + }); + + it('returns null if the scan fails', async () => { + const error = new Error('Scan failed'); + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockRejectedValue(error); + + const result = await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + }); + + expect(result).toBeNull(); + expect(trackError).toHaveBeenCalledWith(error); + }); + + it('tracks security scan completion when account is provided', async () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockResolvedValue({ + status: ScanStatus.SUCCESS, + encoding: 'base58', + error: null, + error_details: null, + request_id: 'test-request-id', + result: { + validation: { + result_type: SecurityAlertResponse.Benign, + reason: '', + features: [], + }, + simulation: { + account_summary: { + account_assets_diff: [], + account_delegations: [], + account_ownerships_diff: [], + total_usd_diff: { in: 0, out: 0, total: 0 }, + }, + }, + }, + } as unknown as SecurityAlertSimulationValidationResponse); + + await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + account: mockAccount, + }); + + // hasSecurityAlerts = false for Benign response + expect( + mockAnalyticsService.trackEventSecurityScanCompleted, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + ScanStatus.SUCCESS, + false, + ); + }); + + it('tracks security alert when malicious transaction is detected', async () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockResolvedValue({ + status: ScanStatus.SUCCESS, + encoding: 'base58', + error: null, + error_details: null, + request_id: 'test-request-id', + result: { + validation: { + result_type: SecurityAlertResponse.Warning, + reason: 'transfer_farming', + features: [], + }, + simulation: { + account_summary: { + account_assets_diff: [], + account_delegations: [], + account_ownerships_diff: [], + total_usd_diff: { in: 0, out: 0, total: 0 }, + }, + }, + }, + } as unknown as SecurityAlertSimulationValidationResponse); + + await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + account: mockAccount, + }); + + // hasSecurityAlerts = true for Warning response + expect( + mockAnalyticsService.trackEventSecurityScanCompleted, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + ScanStatus.SUCCESS, + true, + ); + + expect( + mockAnalyticsService.trackEventSecurityAlertDetected, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + SecurityAlertResponse.Warning, + 'transfer_farming', + "Substantial transfer of the account's assets to untrusted entities", + ); + }); + + it('tracks error when scan fails and account is provided', async () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockRejectedValue(new Error('Scan failed')); + + await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + account: mockAccount, + }); + + expect( + mockAnalyticsService.trackEventSecurityScanCompleted, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + ScanStatus.ERROR, + false, + ); + }); + }); + + describe('getSecurityAlertDescription', () => { + it('returns correct description for known reasons', async () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockResolvedValue({ + status: ScanStatus.SUCCESS, + encoding: 'base58', + error: null, + error_details: null, + request_id: 'test-request-id', + result: { + validation: { + result_type: SecurityAlertResponse.Warning, + reason: 'transfer_farming', + features: [], + }, + simulation: { + account_summary: { + account_assets_diff: [], + account_delegations: [], + account_ownerships_diff: [], + total_usd_diff: { in: 0, out: 0, total: 0 }, + }, + }, + }, + } as unknown as SecurityAlertSimulationValidationResponse); + + await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + account: mockAccount, + }); + + expect( + mockAnalyticsService.trackEventSecurityAlertDetected, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + SecurityAlertResponse.Warning, + 'transfer_farming', + "Substantial transfer of the account's assets to untrusted entities", + ); + }); + + it('returns fallback description for unknown reasons', async () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + jest + .spyOn(mockSecurityAlertsApiClient, 'scanTransactions') + .mockResolvedValue({ + status: ScanStatus.SUCCESS, + encoding: 'base58', + error: null, + error_details: null, + request_id: 'test-request-id', + result: { + validation: { + result_type: SecurityAlertResponse.Warning, + reason: 'unknown_reason', + features: [], + }, + simulation: { + account_summary: { + account_assets_diff: [], + account_delegations: [], + account_ownerships_diff: [], + total_usd_diff: { in: 0, out: 0, total: 0 }, + }, + }, + }, + } as unknown as SecurityAlertSimulationValidationResponse); + + await transactionScanService.scanTransaction({ + method: 'method', + accountAddress: 'accountAddress', + transaction: 'transaction', + scope: Network.Mainnet, + origin: 'https://metamask.io', + account: mockAccount, + }); + + expect( + mockAnalyticsService.trackEventSecurityAlertDetected, + ).toHaveBeenCalledWith( + mockAccount, + 'https://metamask.io', + Network.Mainnet, + SecurityAlertResponse.Warning, + 'unknown_reason', + 'Security alert: unknown_reason', + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.ts b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.ts new file mode 100644 index 00000000..98c54bef --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.ts @@ -0,0 +1,237 @@ +import type { SolanaKeyringAccount } from '../../../entities'; +import type { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; +import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/types'; +import { METAMASK_ORIGIN, METAMASK_ORIGIN_URL } from '../../constants/solana'; +import type { Network } from '../../constants/solana'; +import { trackError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { TransactionScanResult, TransactionScanValidation } from './types'; +import { ScanStatus, SecurityAlertResponse } from './types'; + +export class TransactionScanService { + readonly #securityAlertsApiClient: SecurityAlertsApiClient; + + readonly #logger: ILogger; + + readonly #analyticsService: AnalyticsService; + + constructor( + securityAlertsApiClient: SecurityAlertsApiClient, + analyticsService: AnalyticsService, + logger: ILogger, + ) { + this.#securityAlertsApiClient = securityAlertsApiClient; + this.#analyticsService = analyticsService; + this.#logger = logger; + } + + /** + * Scans a transaction. + * + * @param params - The parameters for the function. + * @param params.method - The method of the transaction. + * @param params.accountAddress - The address of the account. + * @param params.transaction - The transaction to scan. + * @param params.scope - The scope of the transaction. + * @param params.origin - The origin of the transaction. + * @param params.options - The options for the scan. + * @param params.account - The account for analytics tracking. + * @returns The result of the scan. + */ + async scanTransaction({ + method, + accountAddress, + transaction, + scope, + origin, + options = ['simulation', 'validation'], + account, + }: { + method: string; + accountAddress: string; + transaction: string; + scope: Network; + origin: string; + options?: string[]; + account?: SolanaKeyringAccount; + }): Promise { + try { + const result = await this.#securityAlertsApiClient.scanTransactions({ + method, + accountAddress, + transactions: [transaction], + scope, + origin: origin === METAMASK_ORIGIN ? METAMASK_ORIGIN_URL : origin, + options, + }); + + const scan = this.#mapScan(result); + + if (!scan?.status) { + this.#logger.warn( + 'Invalid scan result received from security alerts API', + ); + + if (account) { + await this.#analyticsService.trackEventSecurityScanCompleted( + account, + origin, + scope, + ScanStatus.ERROR, + false, + ); + } + + return null; + } + + // The security scan is completed + if (account) { + const isValidScanStatus = Object.values(ScanStatus).includes( + scan.status as ScanStatus, + ); + const scanStatus = isValidScanStatus + ? (scan.status as ScanStatus) + : ScanStatus.ERROR; + + const hasSecurityAlert = Boolean( + scan.validation?.type && + scan.validation.type !== SecurityAlertResponse.Benign, + ); + + const analyticsPromises = [ + this.#analyticsService.trackEventSecurityScanCompleted( + account, + origin, + scope, + scanStatus, + hasSecurityAlert, + ), + ]; + + if (hasSecurityAlert) { + const isValidSecurityAlertType = Object.values( + SecurityAlertResponse, + ).includes(scan.validation.type as SecurityAlertResponse); + const securityAlertType = isValidSecurityAlertType + ? (scan.validation.type as SecurityAlertResponse) + : SecurityAlertResponse.Warning; + + analyticsPromises.push( + this.#analyticsService.trackEventSecurityAlertDetected( + account, + origin, + scope, + securityAlertType, + scan.validation.reason ?? 'unknown', + this.#getSecurityAlertDescription(scan.validation), + ), + ); + } + + // Run all analytics calls in parallel + await Promise.all(analyticsPromises); + } + + if (!scan?.estimatedChanges?.assets) { + return null; + } + + // Logo URLs are passed directly to Image component - no conversion needed + return scan; + } catch (error) { + await trackError(error); + this.#logger.error(error); + + if (account) { + await this.#analyticsService.trackEventSecurityScanCompleted( + account, + origin, + scope, + ScanStatus.ERROR, + false, + ); + } + + return null; + } + } + + #getSecurityAlertDescription(validation: TransactionScanValidation): string { + if (!validation?.reason) { + return 'Security alert: Unknown reason'; + } + + // Reference: https://docs.blockaid.io/reference/response-reference-solana + const reasonDescriptions: Record = { + unfair_trade: + "Unfair trade of assets, without adequate compensation to the owner's account", + transfer_farming: + "Substantial transfer of the account's assets to untrusted entities", + writable_accounts_farming: + 'Transaction exposes unused writable account, can be utilized in BIT-FLIP attacks patterns', + native_ownership_change: + 'The account transferred ownership of its native SOL to untrusted entities', + spl_token_ownership_change: + 'The account transferred ownership of its SPL tokens to untrusted entities', + exposure_farming: + 'The account delegates ownership, thereby exposing its assets to untrusted spenders', + known_attacker: + "A known attacker's account is involved in the transaction", + invalid_signature: + 'One of the transactions provided contains non valid signatures, that can lead misleading simulation results', + honeypot: + 'The account invests funds in a token that is part of an orchestrated honeypot scheme', + other: + 'The transaction was marked as malicious for other reason, further details would be described in features field', + }; + + return ( + reasonDescriptions[validation.reason] ?? + `Security alert: ${validation.reason}` + ); + } + + #mapScan( + result: SecurityAlertSimulationValidationResponse, + ): TransactionScanResult | null { + // Validate that we have a basic result structure + if (!result) { + return null; + } + + return { + status: + result.status === 'SUCCESS' || result.status === 'ERROR' + ? result.status + : 'ERROR', + estimatedChanges: { + assets: + result.result?.simulation?.account_summary?.account_assets_diff?.map( + (asset) => ({ + type: asset.in ? 'in' : 'out', + symbol: + 'symbol' in asset.asset ? asset.asset.symbol : asset.asset_type, + name: 'name' in asset.asset ? asset.asset.name : asset.asset_type, + logo: 'logo' in asset.asset ? asset.asset.logo : null, + value: asset.in?.value ?? asset.out?.value ?? null, + price: asset.in?.usd_price ?? asset.out?.usd_price ?? null, + }), + ) ?? [], + }, + validation: { + type: result.result?.validation?.result_type ?? null, + reason: result.result?.validation?.reason ?? null, + }, + error: result?.error_details + ? { + type: + 'type' in result.error_details ? result.error_details.type : null, + code: + 'code' in result.error_details ? result.error_details.code : null, + } + : null, + }; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/types.ts b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/types.ts new file mode 100644 index 00000000..1c0f15be --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transaction-scan/types.ts @@ -0,0 +1,48 @@ +import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/types'; + +export type TransactionScanStatus = 'SUCCESS' | 'ERROR'; + +export type TransactionScanAssetChange = { + type: 'in' | 'out'; + value: number | null; + price: number | null; + symbol: string; + name: string; + logo: string | null; +}; + +export type TransactionScanEstimatedChanges = { + assets: TransactionScanAssetChange[]; +}; + +export type TransactionScanValidation = { + type: + | SecurityAlertSimulationValidationResponse['result']['validation']['result_type'] + | null; + reason: + | SecurityAlertSimulationValidationResponse['result']['validation']['reason'] + | null; +}; + +export type TransactionScanError = { + type: string | null; + code: string | null; +}; + +export type TransactionScanResult = { + status: TransactionScanStatus; + estimatedChanges: TransactionScanEstimatedChanges; + validation: TransactionScanValidation; + error: TransactionScanError | null; +}; + +export enum SecurityAlertResponse { + Benign = 'Benign', + Warning = 'Warning', + Malicious = 'Malicious', +} + +export enum ScanStatus { + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts new file mode 100644 index 00000000..9828425b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.test.ts @@ -0,0 +1,1589 @@ +import { address as asAddress } from '@solana/kit'; + +import { KnownCaip19Id, Network, Networks } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import { EXPECTED_FAILED_SWAP_DATA } from '../../test/mocks/transactions-data/failed-swap'; +import { EXPECTED_NATIVE_SOL_TRANSFER_DATA } from '../../test/mocks/transactions-data/native-sol-transfer'; +import { EXPECTED_NATIVE_SOL_TRANSFER_TO_SELF_DATA } from '../../test/mocks/transactions-data/native-sol-transfer-to-self'; +import { EXPECTED_SEND_JUP_TRANSFER_CHECKED_DATA } from '../../test/mocks/transactions-data/send-jup-transfer-checked-to-self'; +import { SEND_PLONK_TO_SELF } from '../../test/mocks/transactions-data/send-plonk-to-self'; +import { EXPECTED_SEND_SPL_TOKEN_AND_CREATE_TOKEN_ACCOUNT_DATA } from '../../test/mocks/transactions-data/send-spl-token-and-create-token-account'; +import { EXPECTED_SEND_USDC_TRANSFER_DATA } from '../../test/mocks/transactions-data/send-usdc-transfer'; +import { EXPECTED_SEND_USDC_TRANSFER_TO_SELF_DATA } from '../../test/mocks/transactions-data/send-usdc-transfer-to-self'; +import { EXPECTED_SEND_USDC_TRANSFER_TO_SELF_2_DATA } from '../../test/mocks/transactions-data/send-usdc-transfer-to-self-2'; +import { EXPECTED_SWAP_A16Z_USDT_SOL_DATA } from '../../test/mocks/transactions-data/swap-a16z-usdt-sol'; +import { EXPECTED_SWAP_SOL_TO_OBRIC_DATA } from '../../test/mocks/transactions-data/swap-sol-to-obric'; +import { EXPECTED_SWAP_SOL_TO_SAHUR_DATA } from '../../test/mocks/transactions-data/swap-sol-to-sahur'; +import { EXPECTED_SWAP_SOL_TO_USDC_DATA } from '../../test/mocks/transactions-data/swap-sol-to-usdc'; +import { EXPECTED_SWAP_TRUMP_TO_JUP_DATA } from '../../test/mocks/transactions-data/swap-trump-to-jup'; +import { EXPECTED_SWAP_USDC_TO_COBIE_DATA } from '../../test/mocks/transactions-data/swap-usdc-to-cobie'; +import { EXPECTED_SWAP_USDC_TO_JUP_DATA } from '../../test/mocks/transactions-data/swap-usdc-to-jup'; +import type { SolanaTransaction } from '../../types/solana'; +import { trackError } from '../../utils/errors'; +import logger from '../../utils/logger'; +import type { AssetsService, TokenHelper } from '../assets'; +import { TransactionMapper } from './TransactionMapper'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('TransactionMapper', () => { + let transactionMapper: TransactionMapper; + let mockTokenHelper: TokenHelper; + let mockAssetsService: AssetsService; + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + + beforeEach(() => { + jest.resetAllMocks(); + + mockTokenHelper = { + amountToUiAmountForMint: jest.fn().mockResolvedValue('1'), + } as unknown as TokenHelper; + + mockAssetsService = { + getAssetsMetadata: jest.fn().mockResolvedValue({}), + } as unknown as AssetsService; + + transactionMapper = new TransactionMapper( + mockTokenHelper, + mockAssetsService, + logger, + ); + }); + + describe('mapRpcTransaction', () => { + it('maps native SOL transfers - as a sender', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_NATIVE_SOL_TRANSFER_DATA, + mockAccount, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736500242, + chain: Network.Mainnet, + status: 'confirmed', + type: 'send', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + to: [ + { + address: 'FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1736500242, + }, + ], + }); + }); + + it('maps native SOL transfers - as a sender to self', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_NATIVE_SOL_TRANSFER_TO_SELF_DATA, + mockAccount, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '4Ccb8PaSob6JjsyDnoFJfUpJZDJHTwcjnK7MxiyVeMtPSsBGKuaMHEVL1VsXTKWS4w26tAhbc3T78aNELjfN8Zwb', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1741791493, + chain: Network.Mainnet, + status: 'confirmed', + type: 'send', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + to: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1741791493, + }, + ], + }); + }); + + it('maps native SOL transfers - as a receiver', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_NATIVE_SOL_TRANSFER_DATA, + { + ...mockAccount, + address: asAddress('FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736500242, + chain: Network.Mainnet, + status: 'confirmed', + type: 'receive', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + to: [ + { + address: 'FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.1', + }, + }, + ], + fees: [], + events: [ + { + status: 'confirmed', + timestamp: 1736500242, + }, + ], + }); + }); + + it('maps native SOL transfers - failure', async () => { + const result = await transactionMapper.mapRpcTransaction( + { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA, + meta: { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA.meta, + // eslint-disable-next-line id-denylist + err: { + DuplicateInstruction: 1, + }, + status: { + Err: { + DuplicateInstruction: 1, + }, + }, + }, + }, + mockAccount, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736500242, + chain: Network.Mainnet, + status: 'failed', + type: 'unknown', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + amount: '0.1', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + to: [ + { + address: 'FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK', + asset: { + amount: '0.1', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'failed', + timestamp: 1736500242, + }, + ], + }); + }); + + it('tracks mapping errors', async () => { + const invalidTransaction = { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA, + transaction: { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA.transaction, + signatures: [], + }, + } as unknown as SolanaTransaction; + + expect( + await transactionMapper.mapRpcTransaction( + invalidTransaction, + mockAccount, + Network.Mainnet, + ), + ).toBeNull(); + + expect(trackError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Transaction ID is required', + }), + ); + }); + + it('maps SPL token transfers - as a sender', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('0.01'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_DATA, + mockAccount, + Network.Devnet, + ); + + expect(result).toStrictEqual({ + id: '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736502537, + chain: Network.Devnet, + status: 'confirmed', + type: 'send', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '0.01', + }, + }, + ], + to: [ + { + address: 'BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '0.01', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Devnet].nativeToken.caip19Id, + unit: Networks[Network.Devnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1736502537, + }, + ], + }); + }); + + it('maps SPL token transfers - as a sender to self', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_TO_SELF_DATA, + mockAccount, + Network.Devnet, + ); + + expect(result).toStrictEqual({ + id: 'fFSAjDzu7CdhzVUUC7DMKf7xuuVn8cZ8njPnpjkTBMHo4Y43SZto2GDuy123yKDoTieihPfDHvBpysE7Eh9aPmH', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1741796354, + chain: Network.Devnet, + status: 'confirmed', + type: 'send', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '1', + }, + }, + ], + to: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '1', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Devnet].nativeToken.caip19Id, + unit: Networks[Network.Devnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1741796354, + }, + ], + }); + }); + + it('maps SPL token transfers - as a sender to self II', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_TO_SELF_2_DATA, + { + ...mockAccount, + address: asAddress('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + }, + Network.Devnet, + ); + + expect(result).toStrictEqual({ + id: 'LPaVYsnhx2q9yTeU7bf5vLESBdm6BYatMSdZqt6CYy8wcX5YFm6rNKZLXJqRA7jyq2w3nbEqfB4qgCFSGS1L6GT', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1747059490, + chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + status: 'confirmed', + type: 'send', + from: [ + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + unit: '', + amount: '1', + }, + }, + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + amount: '0.00203928', + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + unit: 'SOL', + }, + }, + ], + to: [ + { + address: '9fE6zKgca6K2EEa3yjbcq7zGMusUNqSQeWQNL2YDZ2Yi', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + unit: '', + amount: '1', + }, + }, + { + address: 'FrbudWWrsiv5deKG483U91njmeFPV45s3DQUfjhDD2BZ', + asset: { + amount: '0.00203928', + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + unit: 'SOL', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + unit: 'SOL', + amount: '0.00000027', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1747059490 }], + }); + }); + + it('maps SPL token transfers - as a receiver', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('0.01'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_DATA, + { + ...mockAccount, + address: asAddress('BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe'), + }, + Network.Devnet, + ); + + expect(result).toStrictEqual({ + id: '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736502537, + chain: Network.Devnet, + status: 'confirmed', + type: 'receive', + from: [ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '0.01', + }, + }, + ], + to: [ + { + address: 'BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe', + asset: { + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + amount: '0.01', + }, + }, + ], + fees: [], + events: [ + { + status: 'confirmed', + timestamp: 1736502537, + }, + ], + }); + }); + + it('maps SPL token transfers - using TransferChecked', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_JUP_TRANSFER_CHECKED_DATA, + { + ...mockAccount, + address: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '4zvFGpqjihSXgHdw6ymHA8hVfyHURNPwASz4FS4c9wADCMSooojx8k42EUuhoDiGGM73SixUcNXafgnuM5dnKHfH', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1742387710, + chain: Network.Mainnet, + status: 'confirmed', + type: 'send', + from: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + unit: '', + amount: '1', + }, + }, + ], + to: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + unit: '', + amount: '1', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000075001', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1742387710 }], + }); + }); + + it('maps SPL token transfers - where sender has his token account created', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('0.01'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_SPL_TOKEN_AND_CREATE_TOKEN_ACCOUNT_DATA, + { + ...mockAccount, + address: asAddress('BYh4CfuGDvFMKaZp3RPmkw9y6qg3sWukA2TiGJDeLKZi'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '4G24SgaZ3gU92HAB8xwSVg6WXS7NcGtUpHMnQ5RTwBw9bG5x8y6co5TzqqPXbExovY2NAuPjE9393TCHFZVhS8K9', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1745927033, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'receive', + from: [ + { + address: 'EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC', + unit: '', + amount: '0.01', + }, + }, + { + address: 'EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.00207408', + }, + }, + ], + to: [ + { + address: 'BYh4CfuGDvFMKaZp3RPmkw9y6qg3sWukA2TiGJDeLKZi', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC', + unit: '', + amount: '0.01', + }, + }, + ], + fees: [], + events: [{ status: 'confirmed', timestamp: 1745927033 }], + }); + }); + + it.todo('maps SPL token transfers - failure'); + + it('maps swaps - #1 USDC -> Cobie', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('0.01') + .mockResolvedValueOnce('12345') // any value, it's not used + .mockResolvedValueOnce('2583.728601'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_USDC_TO_COBIE_DATA, + { + ...mockAccount, + address: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + }, + Network.Mainnet, + ); + + /** + * Mainnet - Swap + * Transaction: 2pfnv4drhnitfzCFKxiRoJMzFQpG7wZ9mpRQVk7xm5TQ27g6FZH95HVF6KgwQBS872yGtyhuq57jXXS1y29ub11 + * + * Fee Payer: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa pays 0.000074798 SOL + * + * Senders: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa sends 0.01 USDC + * + * Receivers: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa gets 2583.728601 Cobie + */ + expect(result).toStrictEqual({ + id: '2pfnv4drhnitfzCFKxiRoJMzFQpG7wZ9mpRQVk7xm5TQ27g6FZH95HVF6KgwQBS872yGtyhuq57jXXS1y29ub11', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1740480781, + chain: Network.Mainnet, + status: 'confirmed', + type: 'swap', + from: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: `${Network.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + unit: '', + amount: '0.01', + }, + }, + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + amount: '0.000000008', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + to: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: `${Network.Mainnet}/token:HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump`, + unit: '', + amount: '2583.728601', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000069798', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1740480781 }], + }); + }); + + it('maps swaps - #2 USDC -> JUP', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('1.1') + .mockResolvedValueOnce('2.143812'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_USDC_TO_JUP_DATA, + { + ...mockAccount, + address: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + }, + Network.Mainnet, + ); + + /** + * Mainnet - Swap + * Transaction: 5LuTa5k9UvgM2eJknVUD9MjfcmcTP7nvFrCedU8d7ZLXCHbrrwqhXDQYTSfncm1wTSNFPZj3Y4cRkWC8CLG6Zcvh + * + * Fee Payer: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa pays 0.00008 SOL + * + * Senders: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa sends 1.1 USDC + * + * Receivers: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa gets 2.143812 JUP + */ + expect(result).toStrictEqual({ + id: '5LuTa5k9UvgM2eJknVUD9MjfcmcTP7nvFrCedU8d7ZLXCHbrrwqhXDQYTSfncm1wTSNFPZj3Y4cRkWC8CLG6Zcvh', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1742297902, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + unit: '', + amount: '1.1', + }, + }, + ], + to: [ + { + address: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + unit: '', + amount: '2.143812', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000075001', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1742297902 }], + }); + }); + + it('maps swaps - #3 A16Z -> USDT -> SOL', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('1234') // any value, it's not used + .mockResolvedValueOnce('1234') // any value, it's not used + .mockResolvedValueOnce('1234') // any value, it's not used + .mockResolvedValueOnce('0.000028168') + .mockResolvedValueOnce('0.021898077'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_A16Z_USDT_SOL_DATA, + { + ...mockAccount, + address: asAddress('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: 'JiqYGkWcYu8GxPZsMdXDnA8tkZvHnHVmNuKr4JYBErm4rgQWssdHCkbe8MzwwNGndyvyNYaaY5vvMhUMPNiQX9u', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1745425114, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC', + unit: '', + amount: '0.021898077', + }, + }, + ], + to: [ + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000028168', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000017342', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1745425114 }], + }); + }); + + it('maps swaps - #4 TRUMP -> USDC -> WSOL -> JUP', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('0.000437') + .mockResolvedValueOnce('0.05') + .mockResolvedValueOnce('1.265445') + .mockResolvedValueOnce('0.003679489') + .mockResolvedValueOnce('0.543506') + .mockResolvedValueOnce('0.049563') + .mockResolvedValueOnce('0.543506') + .mockResolvedValueOnce('0.003679489') + .mockResolvedValueOnce('1.265445'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_TRUMP_TO_JUP_DATA, + { + ...mockAccount, + address: asAddress('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '4VhDRLUK5QDZ6kgN9PCeEoztUraCibwYA3XaLZUKhfwWxqeN96Qg7Ep4w2j5C1VtggbuU6dqkGczGC537byu9hG3', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1746657467, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: '8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', + unit: '', + amount: '0.05', + }, + }, + { + address: '8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5', + asset: { + amount: '0.00203928', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + to: [ + { + address: '8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + unit: '', + amount: '1.265445', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000036876', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1746657467 }], + }); + }); + + it('maps swaps - #5 SOL -> Sahur, uses swap balance to swap as well as create ATA', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('4036.960523') + .mockResolvedValueOnce('0.01') + .mockResolvedValueOnce('4036.960523') + .mockResolvedValueOnce('4036.960523'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_SOL_TO_SAHUR_DATA, + { + ...mockAccount, + address: asAddress('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: 'QfFVu1P4ji8EFTor4BcRYS84wipv2RJDhyzi3w3YQgv1D8mxz2RhF9cKxi4k6DZdtzDRPL6a346HboDUAsSUCfV', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1747062836, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.01203928', + }, + }, + ], + to: [ + { + address: 'FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump', + unit: '', + amount: '4036.960523', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000006785', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1747062836 }], + }); + }); + + // TODO: An interesting edge case that our current mapping logic doesn't handle well + // We end this transaction with more of all assets than we started with meaning + // we think it's a receive transaction but it's not, it's actually a Swap. + // https://solscan.io/tx/2m8z8uPZyoZwQpissDbhSfW5XDTFmpc7cSFithc5e1w8iCwFcvVkxHeaVhgFSdgUPb5cebbKGjuu48JMLPjfEATr + it.skip('maps swaps - #6 SOL -> USDC', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('0.167225') + .mockResolvedValueOnce('0.00099125') + .mockResolvedValueOnce('0.167225'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_SOL_TO_USDC_DATA, + { + ...mockAccount, + address: asAddress('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '2X4vKsDS56avumw6jh6Z5wj28GNvK5UkMfY1Ta2Mtouic886EGAraa1gsCJ5rkXyHCeL9p2wHRty3QHAhjf352Dp', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1748539118, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.00099125', + }, + }, + ], + to: [ + { + address: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + unit: '', + amount: '0.167225', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000010072', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1748539118 }], + }); + }); + + it.skip('maps swaps - #7 SOL -> OBRIC', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValueOnce('4') + .mockResolvedValueOnce('0.00875') + .mockResolvedValueOnce('0.005903984') + .mockResolvedValueOnce('0.99125'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_SOL_TO_OBRIC_DATA, + { + ...mockAccount, + address: asAddress('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '28rWme56aMyaP8oX18unFeZg65iyDEhjLhvMBpxyFgKcn38P37ZRsssSZoHDCCr5xUfwfpqsVSSBoShLitHQLdrr', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1748545222, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'confirmed', + type: 'swap', + from: [ + { + address: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + unit: 'SOL', + amount: '0.99125', + }, + }, + ], + to: [ + { + address: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: '', + amount: '0.005903984', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.00001212', + }, + }, + ], + events: [{ status: 'confirmed', timestamp: 1748545222 }], + }); + }); + + it('maps swaps - failure', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_FAILED_SWAP_DATA, + { + ...mockAccount, + address: asAddress('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + }, + Network.Mainnet, + ); + + expect(result).toStrictEqual({ + id: '43VK3TtYjN21VG13f2EPvNJxmML38GB8QbTDVtFifzeDW3LQpmLtFdLjERAKwy3k4RUe4Hizmdrj4Nyjm5vYKDBx', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1747040326, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + status: 'failed', + type: 'unknown', + from: [ + { + address: '8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC', + asset: { + amount: '0.006', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + to: [ + { + address: '34FKjAdVcTax2DHqV2XnbXa9J3zmyKcFuFKWbcmgxjgm', + asset: { + amount: '0', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + { + address: '4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA', + asset: { + amount: '0.0000525', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + { + address: '9QQjgi73MnaeeMvUYZr9LTaZj4dt3ktstUF8s25zfiJ1', + asset: { + amount: '0.0059475', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + }, + }, + ], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + unit: 'SOL', + amount: '0.000515451', + }, + }, + ], + events: [{ status: 'failed', timestamp: 1747040326 }], + }); + }); + + it('returns null if the transaction has no signatures', async () => { + const result = await transactionMapper.mapRpcTransaction( + { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA, + transaction: { + ...EXPECTED_NATIVE_SOL_TRANSFER_DATA.transaction, + signatures: [], + }, + }, + mockAccount, + Network.Mainnet, + ); + + expect(result).toBeNull(); + }); + + it('parses empty token balances', async () => { + const result = await transactionMapper.mapRpcTransaction( + { + ...EXPECTED_SEND_USDC_TRANSFER_DATA, + meta: { + ...EXPECTED_SEND_USDC_TRANSFER_DATA.meta, + preTokenBalances: [], + postTokenBalances: [], + } as SolanaTransaction['meta'], + }, + mockAccount, + Network.Devnet, + ); + + expect(result).toStrictEqual({ + id: '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + account: '4b445722-6766-4f99-ade5-c2c9295f21d0', + timestamp: 1736502537, + chain: Network.Devnet, + status: 'confirmed', + type: 'unknown', + from: [], + to: [], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Devnet].nativeToken.caip19Id, + unit: Networks[Network.Devnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: 1736502537, + }, + ], + }); + }); + + it('correctly parses a token send to self with Transfer instruction on token program', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_TO_SELF_DATA, + mockAccount, + Network.Devnet, + ); + + expect(result?.from).toStrictEqual([ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + amount: '1', + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + }, + }, + ]); + + expect(result?.to).toStrictEqual([ + { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + asset: { + amount: '1', + fungible: true, + type: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + unit: '', + }, + }, + ]); + }); + + it('correctly parses a token send to self with TransferChecked instruction on token2022 program', async () => { + const result = await transactionMapper.mapRpcTransaction( + SEND_PLONK_TO_SELF, + { + ...mockAccount, + address: asAddress('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + }, + Network.Mainnet, + ); + + expect(result?.from).toStrictEqual([ + { + address: '8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC', + asset: { + amount: '36', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HeqCcMjmuV5s25J49YiJyT6bD5qWLkP88YPajBySniaV', + unit: '', + }, + }, + ]); + + expect(result?.to).toStrictEqual([ + { + address: '8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC', + asset: { + amount: '36', + fungible: true, + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HeqCcMjmuV5s25J49YiJyT6bD5qWLkP88YPajBySniaV', + unit: '', + }, + }, + ]); + }); + }); + + describe('fee parsing', () => { + it('parses transaction fees on native solana transfers', async () => { + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_NATIVE_SOL_TRANSFER_DATA, + mockAccount, + Network.Localnet, + ); + + expect(result?.fees).toStrictEqual([ + { + asset: { + amount: '0.000005', + fungible: true, + type: KnownCaip19Id.SolLocalnet, + unit: 'SOL', + }, + type: 'base', + }, + ]); + }); + + it('parses transaction fees on SPL transfers', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('1'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_DATA, + mockAccount, + Network.Localnet, + ); + + expect(result?.fees).toStrictEqual([ + { + asset: { + amount: '0.000005', + fungible: true, + type: KnownCaip19Id.SolLocalnet, + unit: 'SOL', + }, + type: 'base', + }, + ]); + }); + + it('parses transaction fees on swap transactions', async () => { + jest + .spyOn(mockTokenHelper, 'amountToUiAmountForMint') + .mockResolvedValue('1'); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SWAP_USDC_TO_COBIE_DATA, + { + ...mockAccount, + address: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + }, + Network.Mainnet, + ); + + expect(result?.fees).toStrictEqual([ + { + type: 'base', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000005', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: Networks[Network.Mainnet].nativeToken.caip19Id, + unit: Networks[Network.Mainnet].nativeToken.symbol, + amount: '0.000069798', + }, + }, + ]); + }); + }); + + describe('enhancement with assets metadata', () => { + describe('when assets metadata is provided as argument', () => { + it('maps transactions with assets metadata', async () => { + const assetsMetadata = { + [KnownCaip19Id.UsdcDevnet]: { + fungible: true, + iconUrl: '', + units: [{ decimals: 6, symbol: 'USDC', name: 'USDC' }], + symbol: 'USDC', + name: 'USDC', + }, + }; + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_DATA, + mockAccount, + Network.Devnet, + assetsMetadata, + ); + + expect(result).toMatchObject({ + from: [ + { + asset: { + unit: 'USDC', + }, + }, + ], + to: [ + { + asset: { + unit: 'USDC', + }, + }, + ], + }); + }); + }); + + describe('when assets metadata is not provided as argument', () => { + it('maps transactions with assets metadata fetched from the assets service', async () => { + jest.spyOn(mockAssetsService, 'getAssetsMetadata').mockResolvedValue({ + [KnownCaip19Id.UsdcDevnet]: { + fungible: true, + iconUrl: '', + units: [{ decimals: 6, symbol: 'USDC', name: 'USDC' }], + symbol: 'USDC', + name: 'USDC', + }, + }); + + const result = await transactionMapper.mapRpcTransaction( + EXPECTED_SEND_USDC_TRANSFER_DATA, + mockAccount, + Network.Devnet, + ); + + expect(result).toMatchObject({ + from: [ + { + asset: { + unit: 'USDC', + }, + }, + ], + to: [ + { + asset: { + unit: 'USDC', + }, + }, + ], + }); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts new file mode 100644 index 00000000..05209e19 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionMapper.ts @@ -0,0 +1,1048 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/utils'; +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { address as asAddress, lamports } from '@solana/kit'; +import type { Address } from '@solana/kit'; +import BigNumber from 'bignumber.js'; +import bs58 from 'bs58'; +import { get } from 'lodash'; + +import type { AssetsService, TokenHelper } from '..'; +import { parseInstruction, toIInstruction } from '../../../entities'; +import type { + InstructionParseResult, + InstructionParseSuccess, + SolanaKeyringAccount, +} from '../../../entities'; +import { LAMPORTS_PER_SOL, Networks } from '../../constants/solana'; +import type { Network } from '../../constants/solana'; +import type { SolanaTransaction } from '../../types/solana'; +import { lamportsToSol } from '../../utils/conversion'; +import { trackError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { AssetMetadata } from '../assets/types'; + +export class TransactionMapper { + readonly #tokenHelper: TokenHelper; + + readonly #assetsService: AssetsService; + + readonly #logger: ILogger; + + constructor( + tokenHelper: TokenHelper, + assetsService: AssetsService, + logger: ILogger, + ) { + this.#tokenHelper = tokenHelper; + this.#assetsService = assetsService; + this.#logger = logger; + } + + /** + * Maps RPC transaction data to a standardized format. + * + * @param transactionData - The raw transaction data from the RPC response. + * @param account - The account associated with the transaction. + * @param scope - The network scope (e.g., Mainnet, Devnet). + * @param assetsMetadata - The assets metadata. + * @returns The mapped transaction data. + */ + async mapRpcTransaction( + transactionData: SolanaTransaction, + account: SolanaKeyringAccount, + scope: Network, + assetsMetadata?: Record, + ): Promise { + try { + const { blockTime } = transactionData; + const { id: accountId, address } = account; + + const id = transactionData.transaction.signatures[0]; + if (!id) { + throw new Error('Transaction ID is required'); + } + + const timestamp = Number(blockTime); + const status = this.#evaluateTransactionStatus(transactionData); + + let fees: Transaction['fees'] = []; + let nativeFrom: Transaction['from'] = []; + let nativeTo: Transaction['to'] = []; + + /** + * If a transaction fails we don't really have access to meaningful `preBalances` + * and `postBalances` values, since nothing really happened. To extract information + * from it we have created this second version of the native transfers parser which + * reads instructions directly, instead of relying on balance differences. + */ + const parser = + status === TransactionStatus.Failed + ? this.#parseTransactionNativeTransfersV2.bind(this) + : this.#parseTransactionNativeTransfers.bind(this); + + const nativeTransfers = parser({ + scope, + transactionData, + }); + + fees = nativeTransfers.fees; + nativeFrom = nativeTransfers.from; + nativeTo = nativeTransfers.to; + + const { from: splFrom, to: splTo } = + await this.#parseTransactionSplTransfers({ + scope, + transactionData, + }); + + let from = [...splFrom, ...nativeFrom]; + let to = [...splTo, ...nativeTo]; + + const type = this.#evaluateTransactionType({ + address: asAddress(address), + status, + from, + to, + }); + + if (type === TransactionType.Swap) { + /** + * If the type is swap: + * - we don't want to include assets that were sent by other addresses + * - we don't want to include assets that were received by other addresses + * - if there are SPL tokens AND SOL in the from and to arrays, we want to remove the SOL + */ + from = from.filter((fromItem) => fromItem.address === address); + to = to.filter((toItem) => toItem.address === address); + } + + if (type === TransactionType.Receive) { + /** + * If the type is receive: + * - we don't want to include assets that were received by other addresses + * - we don't want to include fees as they were not paid by the user + */ + to = to.filter((toItem) => toItem.address === address); + fees = []; + } + + const caip19Ids = [ + ...new Set( + [...from, ...to] + .filter((item) => item.asset?.fungible) + .map((item) => (item.asset as { type: CaipAssetType }).type), + ), + ]; + + const assetsMetadataToUse = + assetsMetadata ?? + (await this.#assetsService.getAssetsMetadata(caip19Ids)); + + from.forEach((item) => { + if (item.asset?.fungible && assetsMetadataToUse[item.asset.type]) { + item.asset.unit = + assetsMetadataToUse[item.asset.type]?.symbol ?? 'UNKNOWN'; + } + }); + + to.forEach((item) => { + if (item.asset?.fungible && assetsMetadataToUse[item.asset.type]) { + item.asset.unit = + assetsMetadataToUse[item.asset.type]?.symbol ?? 'UNKNOWN'; + } + }); + + return { + id, + account: accountId, + timestamp, + chain: scope, + status, + type, + from, + to, + fees, + events: [ + { + status, + timestamp, + }, + ], + }; + } catch (error) { + await trackError(error); + this.#logger.warn(error, 'Error mapping transaction'); + return null; + } + } + + /** + * Evaluates the status of a transaction based on the transaction data. + * + * @param transactionData - The transaction data. + * @returns The status of the transaction. + */ + #evaluateTransactionStatus( + transactionData: SolanaTransaction, + ): TransactionStatus { + const isError = + transactionData.meta?.err || + (transactionData.meta?.status && 'Err' in transactionData.meta.status); + + const status = isError + ? TransactionStatus.Failed + : TransactionStatus.Confirmed; + + return status; + } + + /** + * Evaluates the type of transaction based on the address and the from and to items. + * + * @param params - The options object. + * @param params.address - The address of the user. + * @param params.from - The from items. + * @param params.to - The to items. + * @param params.status - The status of the transaction. + * @returns The type of transaction. + */ + #evaluateTransactionType({ + address, + status, + from, + to, + }: { + address: Address; + status: TransactionStatus; + from: Transaction['from']; + to: Transaction['to']; + }): TransactionType { + if ( + from.length === 0 || + to.length === 0 || + status === TransactionStatus.Failed + ) { + return TransactionType.Unknown; + } + + const userSentItems = from.filter( + (fromItem) => fromItem.address === address, + ); + const userReceivedItems = to.filter((toItem) => toItem.address === address); + + const isAddressSender = userSentItems.length > 0; + const isAddressReceiver = userReceivedItems.length > 0; + + const allSentItemsAreToSelf = from.every((fromItem) => { + return to.some( + (toItem) => + toItem.address === address && + fromItem.asset?.fungible === true && + toItem.asset?.fungible === true && + fromItem.asset.type === toItem.asset.type, + ); + }); + + const allReceivedItemsAreFromSelf = to.every((toItem) => { + return from.some( + (fromItem) => + fromItem.address === address && + fromItem.asset?.fungible === true && + toItem.asset?.fungible === true && + fromItem.asset.type === toItem.asset.type, + ); + }); + + const isSelfTransfer = allSentItemsAreToSelf && allReceivedItemsAreFromSelf; + + if (isSelfTransfer) { + return TransactionType.Send; + } + + if (isAddressSender && isAddressReceiver) { + return TransactionType.Swap; + } + + if (isAddressSender) { + return TransactionType.Send; + } + + return TransactionType.Receive; + } + + /** + * Parses native SOL token transfers from a transaction using its balance changes. + * + * @param options0 - The options object. + * @param options0.scope - The network scope (e.g., Mainnet, Devnet). + * @param options0.transactionData - The raw transaction data containing balance changes. + * @returns Transaction details. + */ + #parseTransactionNativeTransfers({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): { + fees: Transaction['fees']; + from: Transaction['from']; + to: Transaction['to']; + } { + const fees = this.#parseTransactionFees({ + scope, + transactionData, + }); + + const from: Transaction['from'] = []; + const to: Transaction['to'] = []; + + const preBalances = new Map( + transactionData.meta?.preBalances?.map((balance, index) => [ + index, + new BigNumber(balance.toString()), + ]) ?? [], + ); + + const postBalances = new Map( + transactionData.meta?.postBalances?.map((balance, index) => [ + index, + new BigNumber(balance.toString()), + ]) ?? [], + ); + + /** + * The indexes of accounts can be higher than account keys. Don't forget `loadedAddresses`! + * https://solana.stackexchange.com/questions/11981/the-account-index-does-not-exist-in-accountkeys + */ + const allAccountAddresses = [ + ...transactionData.transaction.message.accountKeys, + ...(transactionData.meta?.loadedAddresses?.writable ?? []), + ...(transactionData.meta?.loadedAddresses?.readonly ?? []), + ]; + + /** + * Track all accounts that had SOL balance changes + */ + const allAccountIndexes = new Set([ + ...Array.from(preBalances.keys()), + ...Array.from(postBalances.keys()), + ]); + + for (const accountIndex of allAccountIndexes) { + const address = allAccountAddresses[accountIndex]?.toString(); + + if (!address) { + continue; + } + + const preBalance = preBalances.get(accountIndex) ?? new BigNumber(0); + const postBalance = postBalances.get(accountIndex) ?? new BigNumber(0); + + // Account 0 is the fee payer + const paidFeesSol = + accountIndex === 0 + ? lamportsToSol(transactionData.meta?.fee ?? 0) + : new BigNumber(0); + + /** + * Calculate the delta between the balances and convert from lamports to SOL. + */ + const balanceDiffSol = preBalance + .minus(postBalance) + .dividedBy(new BigNumber(LAMPORTS_PER_SOL)) + .minus(paidFeesSol); + + if (balanceDiffSol.isZero()) { + continue; + } + + const amount = balanceDiffSol.absoluteValue().toString(); + + /** + * If the pre-balance is greater than the post-balance, it means that the account + * has lost SOL, so it's a sender. + */ + if (preBalance.isGreaterThan(postBalance)) { + from.push({ + address, + asset: { + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + amount, + }, + }); + } + + /** + * If the pre-balance is less than the post-balance, it means that the account + * has gained SOL, so it's a receiver. + */ + if (preBalance.isLessThan(postBalance)) { + to.push({ + address, + asset: { + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + amount, + }, + }); + } + } + + /** + * And now we check if there are any native transfers to the same address. + */ + const nativeTransfersToSelf = this.#parseTransactionNativeTransfersToSelf({ + scope, + transactionData, + }); + + if (nativeTransfersToSelf.from.length > 0) { + from.push(...nativeTransfersToSelf.from); + } + + if (nativeTransfersToSelf.to.length > 0) { + to.push(...nativeTransfersToSelf.to); + } + + return { fees, from, to }; + } + + /** + * Parses native SOL token transfers from a transaction using its instructions. + * + * @param options0 - The options object. + * @param options0.scope - The network scope (e.g., Mainnet, Devnet). + * @param options0.transactionData - The raw transaction data containing balance changes. + * @returns Transaction details. + */ + #parseTransactionNativeTransfersV2({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): { + fees: Transaction['fees']; + from: Transaction['from']; + to: Transaction['to']; + } { + const fees = this.#parseTransactionFees({ + scope, + transactionData, + }); + + const allAccountAddresses = [ + ...transactionData.transaction.message.accountKeys, + ...(transactionData.meta?.loadedAddresses?.writable ?? []), + ...(transactionData.meta?.loadedAddresses?.readonly ?? []), + ]; + + const fromMovements: Transaction['from'] = []; + const toMovements: Transaction['to'] = []; + + /** + * For V2 let's go through the instructions and see if there are any native transfers. + */ + const { instructions } = transactionData.transaction.message; + + instructions.forEach((instruction) => { + const { accounts, data, programIdIndex } = instruction; + + const programAddress = allAccountAddresses[programIdIndex]; + + if (programAddress !== SYSTEM_PROGRAM_ADDRESS) { + return; + } + + const [fromAccountIndex, toAccountIndex] = accounts; + + if (fromAccountIndex === undefined || toAccountIndex === undefined) { + return; + } + + const fromAddress = allAccountAddresses[fromAccountIndex]; + const toAddress = allAccountAddresses[toAccountIndex]; + + if (!fromAddress || !toAddress) { + return; + } + + const decodedData = bs58.decode(data); + const opcode = this.#decodeOpcode(decodedData); + + if (opcode !== 2) { + /** + * Not a Transfer instruction + */ + return; + } + + const amount = this.#decodeNativeTransferAmount(decodedData); + + fromMovements.push({ + address: fromAddress, + asset: { + amount: amount.toString(), + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + }, + }); + + toMovements.push({ + address: toAddress, + asset: { + amount: amount.toString(), + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + }, + }); + }); + + /** + * Now we want to merge transfers to the same address of the same asset (type). + */ + const from = this.#aggregateSameAssetMovements(fromMovements); + const to = this.#aggregateSameAssetMovements(toMovements); + + return { + fees, + from, + to, + }; + } + + /** + * There are some transactions that are sent from the same address to itself. + * When this happens, the function above will not find any differences in the balances + * and we will not be able to detect the transfer. + * + * This function is used to parse those transactions. + * + * @param options0 - The options object. + * @param options0.scope - The network scope (e.g., Mainnet, Devnet). + * @param options0.transactionData - The raw transaction data containing token balance changes. + * @returns Transaction transfer details. + */ + #parseTransactionNativeTransfersToSelf({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): { + from: Transaction['from']; + to: Transaction['to']; + } { + const { instructions } = transactionData.transaction.message; + + const from: Transaction['from'] = []; + const to: Transaction['to'] = []; + + const systemProgramAccountIndex = + transactionData.transaction.message.accountKeys.findIndex( + (account) => account === SYSTEM_PROGRAM_ADDRESS, + ); + + /** + * If there are no System Program instructions, then we have no native transfers. + */ + if (systemProgramAccountIndex === -1) { + return { + from, + to, + }; + } + + instructions.forEach((instruction) => { + const { accounts, data, programIdIndex } = instruction; + + if (programIdIndex !== systemProgramAccountIndex) { + return; + } + + const [fromAccountIndex, toAccountIndex] = accounts; + + if ( + fromAccountIndex === undefined || + toAccountIndex === undefined || + fromAccountIndex !== toAccountIndex + ) { + return; + } + + const fromAddress = + transactionData.transaction.message.accountKeys[fromAccountIndex]; + const toAddress = + transactionData.transaction.message.accountKeys[toAccountIndex]; + + if (!fromAddress || !toAddress || fromAddress !== toAddress) { + return; + } + + const amount = this.#decodeNativeTransferAmount(bs58.decode(data)); + + from.push({ + address: fromAddress, + asset: { + amount: amount.toString(), + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + }, + }); + + to.push({ + address: toAddress, + asset: { + amount: amount.toString(), + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + }, + }); + }); + + return { + from, + to, + }; + } + + /** + * Parses SPL token transfers from a transaction data object. + * + * @param options0 - The options object. + * @param options0.scope - The network scope (e.g., Mainnet, Devnet). + * @param options0.transactionData - The raw transaction data containing token balance changes. + * @returns Transaction transfer details. + */ + async #parseTransactionSplTransfers({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): Promise<{ + from: Transaction['from']; + to: Transaction['to']; + }> { + const from: Transaction['from'] = []; + const to: Transaction['to'] = []; + + const preBalances = new Map( + transactionData.meta?.preTokenBalances?.map((balance) => [ + balance.accountIndex, + new BigNumber(balance.uiTokenAmount.amount), + ]) ?? [], + ); + + const postBalances = new Map( + transactionData.meta?.postTokenBalances?.map((balance) => [ + balance.accountIndex, + new BigNumber(balance.uiTokenAmount.amount), + ]) ?? [], + ); + + // Track all accounts that had token balance changes + const allAccountIndexes = new Set([ + ...(transactionData.meta?.preTokenBalances?.map((b) => b.accountIndex) ?? + []), + ...(transactionData.meta?.postTokenBalances?.map((b) => b.accountIndex) ?? + []), + ]); + + for (const accountIndex of allAccountIndexes) { + const preBalance = preBalances.get(accountIndex) ?? new BigNumber(0); + const postBalance = postBalances.get(accountIndex) ?? new BigNumber(0); + const balanceDiff = postBalance.minus(preBalance); + + if (balanceDiff.isZero()) { + continue; + } + + const tokenDetails = + transactionData.meta?.preTokenBalances?.find( + (b) => b.accountIndex === accountIndex, + ) ?? + transactionData.meta?.postTokenBalances?.find( + (b) => b.accountIndex === accountIndex, + ); + + if (!tokenDetails) { + continue; + } + + const { mint, owner } = tokenDetails; + if (!owner) { + continue; + } + + const caip19Id = tokenAddressToCaip19(scope, mint); + + // Convert the raw amount to the ui amount, taking into account the multiplier if any + const rawAmount = balanceDiff.absoluteValue().toString(); + const uiAmount = ( + await this.#tokenHelper.amountToUiAmountForMint( + mint, + scope, + lamports(BigInt(rawAmount)), + ) + ).toString(); + + if (balanceDiff.isNegative()) { + from.push({ + address: owner, + asset: { + fungible: true, + type: caip19Id, + unit: '', // This will get overwritten by the token metadata when we fetch it + amount: uiAmount, + }, + }); + } + + if (balanceDiff.isPositive()) { + to.push({ + address: owner, + asset: { + fungible: true, + type: caip19Id, + unit: '', // This will get overwritten by the token metadata when we fetch it + amount: uiAmount, + }, + }); + } + } + + // And now we check if there are any transfers to the same address. + const transfersToSelf = this.#parseTransactionSplTransfersToSelf({ + scope, + transactionData, + }); + + if (transfersToSelf.from.length > 0) { + from.push(...transfersToSelf.from); + } + + if (transfersToSelf.to.length > 0) { + to.push(...transfersToSelf.to); + } + + return { from, to }; + } + + /** + * Parses SPL token transfers where the sender and receiver are the same address. + * + * @param options0 - The options object. + * @param options0.scope - The network scope (e.g., Mainnet, Devnet). + * @param options0.transactionData - The raw transaction data containing token balance changes. + * @returns Transaction transfer details. + */ + #parseTransactionSplTransfersToSelf({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): { from: Transaction['from']; to: Transaction['to'] } { + const { instructions } = transactionData.transaction.message; + + const from: Transaction['from'] = []; + const to: Transaction['to'] = []; + + // Convert, parse, and filter instructions to only keep self transfers + const selfTransferInstructions = instructions + .map((instruction) => toIInstruction(instruction, transactionData)) + .map(parseInstruction) + .filter(this.#isSelfTransfer.bind(this)); + + // For each self transfer, populate the `from` and `to` arrays + selfTransferInstructions.forEach((instruction) => { + const authority = get(instruction, 'parsed.accounts.authority.address'); + const source = get(instruction, 'parsed.accounts.source.address'); + + if (!authority || !source) { + return; + } + + // Reverse lookup the account index of the source address + const sourceAccountIndex = + transactionData.transaction.message.accountKeys.indexOf( + asAddress(source), + ); + + // Grab the `mint` and `decimals` from preTokenBalances + const { mint, uiTokenAmount: { decimals } = {} } = + transactionData.meta?.preTokenBalances?.find( + (b) => b.accountIndex === sourceAccountIndex, + ) ?? {}; + + if (!mint || decimals === undefined) { + return; + } + + // Compute the amount of the transfer + const rawAmount = instruction.parsed?.data.amount; + const amount = BigNumber(rawAmount) + .dividedBy(new BigNumber(10).pow(decimals)) + .toString(); + + // Convert the mint address to a CAIP-19 ID + const caip19Id = tokenAddressToCaip19(scope, mint); + + from.push({ + address: authority, + asset: { + amount, + fungible: true, + type: caip19Id, + unit: '', + }, + }); + + to.push({ + address: authority, + asset: { + amount, + fungible: true, + type: caip19Id, + unit: '', + }, + }); + }); + + return { + from, + to, + }; + } + + /** + * Parses transaction fees from RPC transaction data. + * + * @param params - The options object. + * @param params.scope - The network scope (e.g., Mainnet, Devnet). + * @param params.transactionData - The raw transaction data containing fee information. + * @returns The parsed fee information including the fee amount in lamports. + */ + #parseTransactionFees({ + scope, + transactionData, + }: { + scope: Network; + transactionData: SolanaTransaction; + }): Transaction['fees'] { + const totalFee = this.#getTransactionTotalFee(transactionData); + const baseFee = this.#getBaseFee(transactionData); + const priorityFee = totalFee.minus(baseFee); + + const fees: Transaction['fees'] = [ + { + type: 'base', + asset: { + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + amount: baseFee.toString(), + }, + }, + ]; + + if (priorityFee?.isGreaterThan(0)) { + fees.push({ + type: 'priority', + asset: { + fungible: true, + type: Networks[scope].nativeToken.caip19Id, + unit: Networks[scope].nativeToken.symbol, + amount: priorityFee.toString(), + }, + }); + } + + return fees; + } + + /** + * Parses the total fee from transaction data. + * + * @param transactionData - The raw transaction data. + * @returns The total fee in lamports. + */ + #getTransactionTotalFee(transactionData: SolanaTransaction): BigNumber { + const feeLamports = new BigNumber( + transactionData.meta?.fee?.toString() ?? '0', + ); + const feeAmount = feeLamports.dividedBy(LAMPORTS_PER_SOL); + return feeAmount; + } + + /** + * Calculates the base fee for a transaction. + * + * @param transactionData - The raw transaction data. + * @returns The base fee in lamports. + */ + #getBaseFee(transactionData: SolanaTransaction): BigNumber { + const numberOfSignatures = transactionData.transaction.signatures.length; + return BigNumber(5000) + .dividedBy(LAMPORTS_PER_SOL) + .multipliedBy(numberOfSignatures); + } + + /** + * Aggregates movements with the same asset type for the same address. + * + * So two movements like this: + * ``` + * [{ + * address: '0x1', + * asset: { + * amount: '50', + * fungible: true, + * type: 'solana:1', + * unit: 'SOL', + * }, + * }, { + * address: '0x1', + * asset: { + * amount: '150', + * fungible: true, + * type: 'solana:1', + * unit: 'SOL', + * }, + * }] + * ``` + * + * will become + * + * ``` + * [{ + * address: '0x1', + * asset: { + * amount: '200', + * fungible: true, + * type: 'solana:1', + * unit: 'SOL', + * }, + * }] + * ``` + * + * @param movements - The movements to aggregate. + * @returns The aggregated movements. + */ + #aggregateSameAssetMovements( + movements: Transaction['from'] | Transaction['to'], + ) { + return movements.reduce( + (acc, movement) => { + // Find existing movement with same asset type + const existingMovement = acc.find((item) => { + // Make sure both assets are fungible and have the same type + return ( + item?.asset?.fungible === true && + movement.asset?.fungible === true && + item.asset.type === movement.asset.type && + item.address === movement.address + ); + }); + + if ( + existingMovement && + existingMovement.asset?.fungible === true && + movement.asset?.fungible === true + ) { + // Convert string amount to BigNumber, add values, then convert back to string + const existingAmount = new BigNumber(existingMovement.asset.amount); + const newAmount = existingAmount.plus(movement.asset.amount); + existingMovement.asset.amount = newAmount.toString(); + } else { + acc.push(movement); + } + + return acc; + }, + [], + ); + } + + /** + * Decodes the amount from the instruction data. + * + * @param data - The instruction data. + * @returns The amount. + */ + #decodeOpcode(data: Uint8Array) { + /** + * Opcode is the first 4 bytes of the instruction data. + */ + let raw = BigInt(0); + for (let i = 0; i < 4; i++) { + // eslint-disable-next-line no-bitwise + raw |= BigInt(data[i] ?? 0) << BigInt(8 * i); + } + + return Number(raw); + } + + /** + * Decodes the amount from the instruction data. + * + * @param data - The instruction data. + * @returns The amount. + */ + #decodeNativeTransferAmount(data: Uint8Array): BigNumber { + /** + * Native Solana transfers have a fixed length of 12 bytes. + * 4 bytes - Instruction index + * 8 bytes - Transfer amount unsigned int 64 + */ + let raw = BigInt(0); + for (let i = 4; i < 12; i++) { + // eslint-disable-next-line no-bitwise + raw |= BigInt(data[i] ?? 0) << BigInt(8 * (i - 4)); + } + + return BigNumber(raw.toString()).dividedBy(LAMPORTS_PER_SOL); + } + + /** + * Checks if an instruction is a self transfer. + * + * @param instruction - The instruction to check. + * @returns True if the instruction is a self transfer, false otherwise. + */ + #isSelfTransfer( + instruction: InstructionParseResult, + ): instruction is InstructionParseSuccess { + if ( + instruction.type !== 'Transfer' && + instruction.type !== 'TransferChecked' + ) { + return false; + } + + if (!instruction.parsed) { + return false; + } + + const { source, destination, authority } = + instruction.parsed.accounts ?? {}; + + if (!source || !destination || !authority) { + return false; + } + + return source.address === destination.address; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts new file mode 100644 index 00000000..272f1311 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts @@ -0,0 +1,202 @@ +import { TransactionStatus } from '@metamask/keyring-api'; +import type { Transaction } from '@metamask/keyring-api'; +import { cloneDeep } from 'lodash'; + +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import { InMemoryState } from '../state/InMemoryState'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; +import { TransactionsRepository } from './TransactionsRepository'; + +describe('TransactionsRepository', () => { + let repository: TransactionsRepository; + let mockState: IStateManager; + + const mockAccount0 = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockAccount1 = MOCK_SOLANA_KEYRING_ACCOUNT_1; + + const createMockTransaction = ( + accountId: string, + signature: string, + status: TransactionStatus = TransactionStatus.Confirmed, + timestamp = 1234567890, + ) => + ({ + account: accountId, + id: signature, + status, + timestamp, + }) as unknown as Transaction; + + // Create two transactions for account 0 + const mockTransaction00 = createMockTransaction( + mockAccount0.id, + 'mock-signature-00', + ); + const mockTransaction01 = createMockTransaction( + mockAccount0.id, + 'mock-signature-01', + ); + + // Create one transaction for account 1 + const mockTransaction10 = createMockTransaction( + mockAccount1.id, + 'mock-signature-10', + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockState = new InMemoryState(cloneDeep(DEFAULT_UNENCRYPTED_STATE)); + repository = new TransactionsRepository(mockState); + }); + + describe('findByAccountId', () => { + it('returns an empty array when there are no transactions', async () => { + const transactions = await repository.findByAccountId(mockAccount0.id); + expect(transactions).toStrictEqual([]); + }); + + it('returns the transactions for the given account', async () => { + // Init a state with two transactions for account 0 + await mockState.update((state) => ({ + ...state, + transactions: { + [mockAccount0.id]: [mockTransaction00, mockTransaction01], + }, + })); + + const transactions = await repository.findByAccountId(mockAccount0.id); + expect(transactions).toStrictEqual([ + mockTransaction00, + mockTransaction01, + ]); + }); + }); + + describe('save', () => { + it('saves a transaction to the state', async () => { + const stateUpdateSpy = jest.spyOn(mockState, 'update'); + + await repository.save(mockTransaction00); + + expect(stateUpdateSpy).toHaveBeenCalledTimes(1); + + const state = await mockState.get(); + expect(state).toStrictEqual({ + ...DEFAULT_UNENCRYPTED_STATE, + transactions: { + [mockAccount0.id]: [mockTransaction00], + }, + }); + }); + }); + + describe('saveMany', () => { + it('saves transactions to the state when there are no existing transactions', async () => { + // Initially the state has no transactions + + const stateUpdateSpy = jest.spyOn(mockState, 'update'); + + await repository.saveMany([ + mockTransaction00, + mockTransaction01, + mockTransaction10, + ]); + + expect(stateUpdateSpy).toHaveBeenCalledTimes(1); + + const state = await mockState.get(); + expect(state).toStrictEqual({ + ...DEFAULT_UNENCRYPTED_STATE, + transactions: { + [mockAccount0.id]: [mockTransaction00, mockTransaction01], + [mockAccount1.id]: [mockTransaction10], + }, + }); + }); + + it('adds new transactions to the state', async () => { + // Init a state with one transaction + const mockStateValue = { + ...DEFAULT_UNENCRYPTED_STATE, + transactions: { + [mockAccount0.id]: [mockTransaction00], + }, + }; + await mockState.update(() => mockStateValue); + const stateUpdateSpy = jest.spyOn(mockState, 'update'); + + await repository.saveMany([mockTransaction01, mockTransaction10]); + + expect(stateUpdateSpy).toHaveBeenCalledTimes(1); + + const state = await mockState.get(); + expect(state).toStrictEqual({ + ...mockStateValue, + transactions: { + [mockAccount0.id]: [mockTransaction00, mockTransaction01], + [mockAccount1.id]: [mockTransaction10], + }, + }); + }); + + it('overrides existing transactions', async () => { + const mockStateValue = { + ...DEFAULT_UNENCRYPTED_STATE, + transactions: { + [mockAccount0.id]: [mockTransaction00], + }, + }; + await mockState.update(() => mockStateValue); + const stateUpdateSpy = jest.spyOn(mockState, 'update'); + + const mockTransaction00Overridden = { + ...mockTransaction00, + status: TransactionStatus.Failed, + }; + + await repository.saveMany([mockTransaction00Overridden]); + + expect(stateUpdateSpy).toHaveBeenCalledTimes(1); + + const state = await mockState.get(); + expect(state).toStrictEqual({ + ...mockStateValue, + transactions: { + [mockAccount0.id]: [mockTransaction00Overridden], + }, + }); + }); + + it('sorts transactions by timestamp in descending order', async () => { + const mockOldTransaction = createMockTransaction( + mockAccount0.id, + 'mock-signature-old', + TransactionStatus.Confirmed, + 123, + ); + + const mockRecentTransaction = createMockTransaction( + mockAccount0.id, + 'mock-signature-recent', + TransactionStatus.Confirmed, + 999, + ); + + // Pass the transactions in reverse order to test the sorting + await repository.saveMany([mockOldTransaction, mockRecentTransaction]); + + const state = await mockState.get(); + expect(state).toStrictEqual({ + ...DEFAULT_UNENCRYPTED_STATE, + transactions: { + [mockAccount0.id]: [mockRecentTransaction, mockOldTransaction], + }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts new file mode 100644 index 00000000..e589fd48 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts @@ -0,0 +1,70 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { chain } from 'lodash'; + +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class TransactionsRepository { + readonly #state: IStateManager; + + readonly #stateKey = 'transactions'; + + constructor(state: IStateManager) { + this.#state = state; + } + + async getAll(): Promise { + const transactionsByAccount = await this.#state.getKey< + UnencryptedStateValue['transactions'] + >(this.#stateKey); + + return Object.values(transactionsByAccount ?? {}).flat(); + } + + async findByAccountId(accountId: string): Promise { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return transactions ?? []; + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + // Optimize the sate operations by reading and writing to the state only once + await this.#state.update((state) => { + const allTransactionsByAccount = state[this.#stateKey]; + + transactions.forEach((transaction) => { + const signature = transaction.id; + const accountId = transaction.account; + const existingTransactionsForAccount = + allTransactionsByAccount[accountId] ?? []; + + // Avoid duplicates. If a transaction with the same signature already exists, override it. + const sameSignatureTransactionIndex = + existingTransactionsForAccount.findIndex((tx) => tx.id === signature); + + if (sameSignatureTransactionIndex !== -1) { + existingTransactionsForAccount[sameSignatureTransactionIndex] = + transaction; + } + + const updatedTransactions = chain([ + ...existingTransactionsForAccount, + transaction, + ]) + .uniqBy('id') + .sortBy((item) => -(item.timestamp ?? 0)) // Sort by timestamp in descending order + .value(); + + state[this.#stateKey][accountId] = updatedTransactions; + }); + + return state; + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts new file mode 100644 index 00000000..6bcba5b6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { Transaction } from '@metamask/keyring-api'; +import { address as asAddress } from '@solana/kit'; +import type { NativeAsset } from 'src/entities'; + +import { KnownCaip19Id, Network } from '../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../test/mocks/solana-keyring-accounts'; +import { MOCK_GET_SIGNATURES_FOR_ADDRESS } from '../../test/mocks/transactions'; +import { ADDRESS_1_TRANSACTION_1_DATA } from '../../test/mocks/transactions-data/address-1/transaction-1'; +import { trackError } from '../../utils/errors'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { AssetsService } from '../assets/AssetsService'; +import type { SolanaConnection } from '../connection/SolanaConnection'; +import { mockLogger } from '../mocks/logger'; +import { createMockConnection } from '../mocks/mockConnection'; +import type { TransactionMapper } from './TransactionMapper'; +import type { TransactionsRepository } from './TransactionsRepository'; +import { TransactionsService } from './TransactionsService'; + +jest.mock('../../utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +describe('TransactionsService', () => { + let mockTransactionsRepository: TransactionsRepository; + let mockTransactionMapper: TransactionMapper; + let mockAccountsService: AccountsService; + let mockConnection: SolanaConnection; + let mockAssetsService: AssetsService; + let service: TransactionsService; + + beforeEach(() => { + mockTransactionsRepository = { + findByAccountId: jest.fn(), + getAll: jest.fn(), + saveMany: jest.fn(), + } as unknown as TransactionsRepository; + + mockTransactionMapper = { + mapRpcTransaction: jest.fn(), + } as unknown as TransactionMapper; + + mockAssetsService = { + getAssetsMetadata: jest.fn(), + } as unknown as AssetsService; + + mockConnection = createMockConnection(); + + mockAccountsService = { + getAll: jest.fn(), + } as unknown as AccountsService; + + service = new TransactionsService( + mockTransactionsRepository, + mockTransactionMapper, + mockAccountsService, + mockAssetsService, + mockConnection, + mockLogger, + ); + + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + }); + + describe('fetchBySignature', () => { + const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockScope = Network.Mainnet; + const mockTransactionData = ADDRESS_1_TRANSACTION_1_DATA; + const mockMappedTransaction = {} as unknown as Transaction; + const mockSignature = mockTransactionData.transaction.signatures[1]!; + + it('fetches and returns a transaction by signature', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getTransaction: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(mockTransactionData), + }), + } as any); + + jest + .spyOn(mockTransactionMapper, 'mapRpcTransaction') + .mockResolvedValue(mockMappedTransaction); + + const result = await service.fetchBySignature( + mockSignature, + mockAccount, + mockScope, + ); + + expect(result).toStrictEqual(mockMappedTransaction); + }); + + it('returns null if the transaction is not found', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getTransaction: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(null), + }), + } as any); + + const result = await service.fetchBySignature( + mockSignature, + mockAccount, + mockScope, + ); + + expect(result).toBeNull(); + }); + }); + + describe('fetchLatestSignatures', () => { + it('fetches and returns signatures for the given address', async () => { + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignaturesForAddress: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(MOCK_GET_SIGNATURES_FOR_ADDRESS), + }), + } as any); + + const result = await service.fetchLatestSignatures( + Network.Localnet, + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + { limit: 10 }, + ); + + expect(result).toStrictEqual([ + '3B7H4E2ih3Tcas6um1izEBZagVfLoxSUfZSKkSNSu7mh4nAy7ZafaEgKhH4d1NBY2MMRWgyPX2LcMbKYwphR8dRq', + '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + '54Lz5p2zQNU6ngvyGtpeMYEdGoHG2D7ByPS2n3Wa4QNHzqTZ46sUemk1PxSrM6UieQ2i15XiRrTuxZyiPkg8V1vW', + '2a5UXcyb6Gz8DH5MdumBvoGQiHLjTKfPcKrAGcsPrVSUjM9NRVUB1TuL1sNEj59nKBzfLm3Z2RvtsnCGZHa7KXPB', + 'yftYXx1xSmLiMeJ2mGkpZd7Xd13mtW7juWcRnihMhDz1zAeCrq5rPrw7WoCkhEcfUL7MwYCti9Q8bWRdJKZuris', + '24pkWA6oUqtKs1nqx4ZFqW3DoeNcVHC57s1azr63EzaXsDNJAkejmyjB7QonVqvm3cC8cVtbN11jSWTu1xUurQZ9', + '27kCW7f9RCWDkQSqSDrwvbJ3d8mgaFmLLu7GsVujJnp55ue8mQNHvphoVEEF32mXUWZSagdXNraZ7zszBENgAY7T', + '5XpBS9D4bBhc4F69SJd3th19Xe8qhqPyJ3MKWhRLF3tbeHTbSLZSM9UUztJc7pLTASUd2jNR67y2W3Q6LogUnai7', + '5iFQpCwAgiXebzuKxLfhePscR9EYRvRNRSx2Mbj12ed36zNkGmQMkg7ekFXjh88R3p75D6uNK45hgRxC6FyUDnhE', + ]); + }); + }); + + describe('findByAccounts', () => { + it('fetches and returns transactions for the given accounts', async () => { + const mockAccount0 = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const mockAccount1 = MOCK_SOLANA_KEYRING_ACCOUNT_1; + + const mockTransaction00 = {} as unknown as Transaction; + const mockTransaction01 = {} as unknown as Transaction; + const mockTransaction10 = {} as unknown as Transaction; + + jest + .spyOn(mockTransactionsRepository, 'findByAccountId') + .mockResolvedValueOnce([mockTransaction00, mockTransaction01]) + .mockResolvedValueOnce([mockTransaction10]); + + const result = await service.findByAccounts([mockAccount0, mockAccount1]); + + expect(result).toStrictEqual([ + mockTransaction00, + mockTransaction01, + mockTransaction10, + ]); + }); + }); + + describe('fetchAssetsTransactions', () => { + it('tracks transaction fetch errors and continues', async () => { + const asset = { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1', + uiAmount: '1', + } as NativeAsset; + const error = new Error('RPC failed'); + + jest + .spyOn(mockAccountsService, 'getAll') + .mockResolvedValue([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + jest.spyOn(mockAssetsService, 'getAssetsMetadata').mockResolvedValue({}); + jest.spyOn(mockTransactionsRepository, 'getAll').mockResolvedValue([]); + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignaturesForAddress: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockResolvedValue([MOCK_GET_SIGNATURES_FOR_ADDRESS[0]]), + }), + getTransaction: jest.fn().mockReturnValue({ + send: jest.fn().mockRejectedValue(error), + }), + } as any); + + expect(await service.fetchAssetsTransactions([asset])).toStrictEqual([]); + expect(trackError).toHaveBeenCalledTimes(1); + expect(trackError).toHaveBeenCalledWith(error); + }); + }); + + describe('save', () => { + it('saves a transaction', async () => { + const mockTransaction = {} as unknown as Transaction; + + await service.save(mockTransaction); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([ + mockTransaction, + ]); + }); + }); + + describe('saveMany', () => { + it('saves multiple transactions', async () => { + const transactions = [ + {} as unknown as Transaction, + {} as unknown as Transaction, + ]; + + await service.saveMany(transactions); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + transactions, + ); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts new file mode 100644 index 00000000..400e0a08 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -0,0 +1,294 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { Transaction } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { Address, Commitment, Signature, Slot } from '@solana/kit'; +import { address as asAddress, signature as asSignature } from '@solana/kit'; +import { get, groupBy } from 'lodash'; + +import type { AssetEntity } from '../../../entities'; +import type { SolanaKeyringAccount } from '../../../entities/keyring-account'; +import type { Network } from '../../constants/solana'; +import type { SolanaTransaction } from '../../types/solana'; +import { trackError } from '../../utils/errors'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { AccountsService } from '../accounts'; +import type { AssetsService } from '../assets/AssetsService'; +import type { SolanaConnection } from '../connection'; +import type { TransactionMapper } from './TransactionMapper'; +import type { TransactionsRepository } from './TransactionsRepository'; +import { isSpam } from './utils/isSpam'; + +export class TransactionsService { + readonly #transactionsRepository: TransactionsRepository; + + readonly #transactionMapper: TransactionMapper; + + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #connection: SolanaConnection; + + readonly #logger: ILogger; + + constructor( + transactionsRepository: TransactionsRepository, + transactionMapper: TransactionMapper, + accountsService: AccountsService, + assetsService: AssetsService, + connection: SolanaConnection, + logger: ILogger, + ) { + this.#transactionsRepository = transactionsRepository; + this.#transactionMapper = transactionMapper; + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#connection = connection; + this.#logger = createPrefixedLogger(logger, '[💱 TransactionsService]'); + } + + async fetchBySignature( + signature: string, + account: SolanaKeyringAccount, + scope: Network, + ): Promise { + const transactionData = await this.#connection + .getRpc(scope) + .getTransaction(asSignature(signature), { + maxSupportedTransactionVersion: 0, + }) + .send(); + + if (!transactionData) { + return null; + } + + return this.#transactionMapper.mapRpcTransaction( + transactionData, + account, + scope, + ); + } + + /** + * Fetches the transactions for the given assets. + * Only fetches the transactions that are not already in the state. + * + * @param assets - The assets to fetch the transactions for. + * @param options - The options for the fetch. + * @param options.limit - The maximum number of transactions to fetch. + * @returns The transactions for the given assets. + */ + async fetchAssetsTransactions( + assets: AssetEntity[], + options?: { + limit?: number; + }, + ): Promise { + const accounts = await this.#accountsService.getAll(); + + const assetTypes = assets.map((asset) => asset.assetType); + + const assetsMetadata = + await this.#assetsService.getAssetsMetadata(assetTypes); + + const savedTransactions = await this.#transactionsRepository.getAll(); + + const findLatestTransactionForAsset = async (asset: AssetEntity) => { + const { network } = asset; + const addressOrMint = 'mint' in asset ? asset.mint : asset.address; + + const existingTransaction = savedTransactions + .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) + .find( + (tx) => + tx.from.some( + (from) => + tokenAddressToCaip19(network, addressOrMint) === + get(from, 'asset.type'), + ) || + tx.to.some( + (to) => + tokenAddressToCaip19(network, addressOrMint) === + get(to, 'asset.type'), + ), + ); + + if (!existingTransaction) { + return null; + } + + return existingTransaction; + }; + + type SignatureWithAsset = { + signatureResponse: { + signature: Signature; + blockTime: number; + }; + asset: AssetEntity; + }; + + const fetchSignaturesForAsset = async ( + asset: AssetEntity, + ): Promise => { + const { network } = asset; + const addressOrPubkey = 'pubkey' in asset ? asset.pubkey : asset.address; + + const latestTransaction = await findLatestTransactionForAsset(asset); + + const latestSignature = latestTransaction + ? asSignature(latestTransaction?.id) + : undefined; + + const response = await this.#connection + .getRpc(network) + .getSignaturesForAddress(asAddress(addressOrPubkey), { + limit: 5, + ...(latestSignature ? { until: latestSignature } : {}), + }) + .send(); + + return response.map((item) => ({ + signatureResponse: { + signature: item.signature, + blockTime: Number(item.blockTime ?? 0), + }, + asset, + })); + }; + + const signatures = ( + await Promise.all(assets.map(fetchSignaturesForAsset)) + ).flat(); + + // If limit is provided, only fetch the most recent signatures with the limit + const signaturesToFetch = options?.limit + ? signatures + .sort( + (a, b) => + (b.signatureResponse.blockTime ?? 0) - + (a.signatureResponse.blockTime ?? 0), + ) + .slice(0, options.limit) + : signatures; + + type TransactionWithAsset = { + transaction: SolanaTransaction | null; + asset: AssetEntity; + }; + + const fetchTransaction = async ( + signatureWithAsset: SignatureWithAsset, + ): Promise => { + try { + const { signatureResponse, asset } = signatureWithAsset; + const transaction = await this.#connection + .getRpc(asset.network) + .getTransaction(asSignature(signatureResponse.signature), { + maxSupportedTransactionVersion: 0, + }) + .send(); + return { + transaction, + asset, + }; + } catch (error) { + await trackError(error); + return null; + } + }; + + const transactions = ( + await Promise.all(signaturesToFetch.map(fetchTransaction)) + ).filter((item) => item !== null); + + const findAccountById = (id: string) => + accounts.find((account) => account.id === id); + + const mapTransaction = async ( + transactionWithAsset: TransactionWithAsset, + ) => { + const { transaction, asset } = transactionWithAsset; + if (!transaction) { + return null; + } + const account = findAccountById(asset.keyringAccountId); + if (!account) { + return null; + } + return this.#transactionMapper.mapRpcTransaction( + transaction, + account, + asset.network, + assetsMetadata, + ); + }; + + const mappedTransactions = ( + await Promise.all(transactions.map(mapTransaction)) + ) + .filter((item) => item !== null) + .filter((item) => { + const account = findAccountById(item.account); + if (!account) { + return false; + } + return !isSpam(item, account); + }); + + return mappedTransactions; + } + + async fetchLatestSignatures( + scope: Network, + address: Address, + config?: { + /** start searching backwards from this transaction signature. If not provided the search starts from the top of the highest max confirmed block. */ + before?: Signature; + commitment?: Exclude; + /** maximum transaction signatures to return (between 1 and 1,000). Default: 1000 */ + limit?: number; + /** The minimum slot that the request can be evaluated at */ + minContextSlot?: Slot; + /** search until this transaction signature, if found before limit reached */ + until?: Signature; + }, + ): Promise { + const signatureResponses = await this.#connection + .getRpc(scope) + .getSignaturesForAddress(address, config) + .send(); + const signatures = signatureResponses.map(({ signature }) => signature); + + return signatures; + } + + async findByAccounts( + accounts: SolanaKeyringAccount[], + ): Promise { + const transactions = await Promise.all( + accounts.map(async (account) => + this.#transactionsRepository.findByAccountId(account.id), + ), + ); + + return transactions.flat(); + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + await this.#transactionsRepository.saveMany(transactions); + + const transactionsByAccountId = groupBy(transactions, 'account'); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: transactionsByAccountId, + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/index.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/index.ts new file mode 100644 index 00000000..1ff0eaf5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/index.ts @@ -0,0 +1,3 @@ +export * from './TransactionMapper'; +export * from './TransactionsRepository'; +export * from './TransactionsService'; diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/types.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/types.ts new file mode 100644 index 00000000..fbc1deda --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/types.ts @@ -0,0 +1,8 @@ +import type { Network } from '../../constants/solana'; + +export type SignatureMapping = { + // For bulk fetching: All signatures for a network + byNetwork: Map>; + // For account mapping: Signatures for each account+network combination + byAccountAndNetwork: Map>>; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.test.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.test.ts new file mode 100644 index 00000000..aee19f4e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.test.ts @@ -0,0 +1,74 @@ +import { address as asAddress } from '@solana/kit'; + +import { Network } from '../../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; +import { EXPECTED_SPAM_TRANSACTION_DATA } from '../../../test/mocks/transactions-data/spam'; +import { EXPECTED_SPAM_TRANSACTION_DATA_2 } from '../../../test/mocks/transactions-data/spam-2'; +import type { AssetsService } from '../../assets/AssetsService'; +import type { TokenHelper } from '../../assets/TokenHelper'; +import { mockLogger } from '../../mocks/logger'; +import { TransactionMapper } from '../TransactionMapper'; +import { isSpam } from './isSpam'; + +describe('isSpam', () => { + const scope = Network.Mainnet; + let transactionMapper: TransactionMapper; + + beforeEach(() => { + const mockTokenHelper = { + amountToUiAmountForMint: jest.fn().mockResolvedValue('1'), + } as unknown as TokenHelper; + + const mockAssetsService = { + getAssetsMetadata: jest.fn().mockResolvedValue({}), + } as unknown as AssetsService; + + transactionMapper = new TransactionMapper( + mockTokenHelper, + mockAssetsService, + mockLogger, + ); + }); + + it('returns true if the transaction is a spam transaction - #1', async () => { + const account = { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + address: asAddress('DAXnAudMEqiD1sS1rFn4ds3pdybRYJd9J58PqCncVVqS'), + }; + + const transaction = await transactionMapper.mapRpcTransaction( + EXPECTED_SPAM_TRANSACTION_DATA, + account, + scope, + ); + + if (!transaction) { + throw new Error('Transaction is null'); + } + + const result = isSpam(transaction, account); + + expect(result).toBe(true); + }); + + it('returns true if the transaction is a spam transaction - #2', async () => { + const account = { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + address: asAddress('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + }; + + const transaction = await transactionMapper.mapRpcTransaction( + EXPECTED_SPAM_TRANSACTION_DATA_2, + account, + scope, + ); + + if (!transaction) { + throw new Error('Transaction is null'); + } + + const result = isSpam(transaction, account); + + expect(result).toBe(true); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.ts b/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.ts new file mode 100644 index 00000000..21d15c62 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/transactions/utils/isSpam.ts @@ -0,0 +1,75 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import BigNumber from 'bignumber.js'; + +import type { SolanaKeyringAccount } from '../../../../entities'; +import { KnownCaip19Id } from '../../../constants/solana'; + +// A function that returns true if it believes that the passed transaction is a spam, or false if it believes it's legitimate. +type SpamDetector = ( + transaction: Transaction, + account: SolanaKeyringAccount, +) => boolean; + +/** + * Spam Detector #1: It categorizes transactions as spam if they receive less than 0.001 SOL. + * + * @param transaction - The transaction to evaluate. + * @param account - The account associated with the transaction. + * @returns Whether the transaction passes the minimum SOL amount check (true = passes/legitimate). + */ +const isSolAmountLowerThanThreshold: SpamDetector = ( + transaction, + account, +): boolean => { + const { to, type, status } = transaction; + const { address } = account; + + // This checker only applies to receive transactions or failed transactions. + const isApplicable = + type === TransactionType.Receive || status === TransactionStatus.Failed; + + if (!isApplicable) { + return false; + } + + const { hasReceivedSOL, receivedSOLAmount } = to.reduce( + (acc, toItem) => { + if ( + toItem.address === address && + toItem.asset?.fungible && + (toItem.asset.type === KnownCaip19Id.SolMainnet || + toItem.asset.type === KnownCaip19Id.SolDevnet) + ) { + return { + hasReceivedSOL: true, + receivedSOLAmount: acc.receivedSOLAmount.plus(toItem.asset.amount), + }; + } + + return acc; + }, + { hasReceivedSOL: false, receivedSOLAmount: new BigNumber(0) }, + ); + + return hasReceivedSOL && receivedSOLAmount.isLessThan(0.001); +}; + +/** + * Evaluates the legitimacy of a transaction based on various detectors. + * + * @param transaction - The transaction to evaluate. + * @param account - The account associated with the transaction. + * @returns True if the transaction is spam, false if it's legitimate. + */ +export function isSpam( + transaction: Transaction, + account: SolanaKeyringAccount, +): boolean { + const detectors: SpamDetector[] = [ + isSolAmountLowerThanThreshold, + // Register more detectors here. + ]; + + return detectors.some((detect) => detect(transaction, account)); +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/README.md b/merged-packages/solana-wallet-snap/src/core/services/wallet/README.md new file mode 100644 index 00000000..3790a1d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/wallet/README.md @@ -0,0 +1,9 @@ +# Wallet + +The `wallet` module exposes the methods `signTransaction`, `signAndSendTransation`, `signMessage`, and `signIn` to satisfy to [wallet-standard](https://github.com/wallet-standard/wallet-standard). + +## Flow `signTransaction` + +![Transaction Signing Flow Schema](./img/transaction-signing-flow.png) + +This schema illustrates the transaction signing flow in our Solana wallet implementation. diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts new file mode 100644 index 00000000..77299121 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -0,0 +1,406 @@ +import { SolMethod } from '@metamask/keyring-api'; + +import { Network } from '../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNTS, +} from '../../test/mocks/solana-keyring-accounts'; +import { getBip32EntropyMock } from '../../test/mocks/utils/getBip32Entropy'; +import logger from '../../utils/logger'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { SolanaConnection } from '../connection'; +import { createMockConnection } from '../mocks/mockConnection'; +import { MOCK_EXECUTION_SCENARIOS } from '../signer/mocks/scenarios'; +import type { Signer } from '../signer/Signer'; +import type { SignatureMonitor } from '../subscriptions'; +import { + MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, + MOCK_SIGN_IN_REQUEST, + MOCK_SIGN_IN_RESPONSE, + MOCK_SIGN_MESSAGE_REQUEST, + MOCK_SIGN_MESSAGE_RESPONSE, + MOCK_SIGN_TRANSACTION_REQUEST, +} from './mocks'; +import type { SolanaWalletRequest } from './structs'; +import { WalletService } from './WalletService'; + +jest.mock('../../utils/getBip32Entropy', () => ({ + getBip32Entropy: getBip32EntropyMock, +})); + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +describe('WalletService', () => { + let mockConnection: SolanaConnection; + let mockSigner: Signer; + let mockSignatureMonitor: SignatureMonitor; + let mockAnalyticsService: AnalyticsService; + let service: WalletService; + const mockAccounts = [...MOCK_SOLANA_KEYRING_ACCOUNTS]; + let onCommitmentReachedCallback: (params: any) => Promise; + const origin = 'https://metamask.io'; + + beforeEach(() => { + mockConnection = createMockConnection(); + + mockSigner = { + partiallySignBase64String: jest.fn(), + } as unknown as Signer; + + mockSignatureMonitor = { + monitor: jest.fn(), + } as unknown as SignatureMonitor; + + // Mock the monitor method to capture the onCommitmentReached callback + (mockSignatureMonitor.monitor as jest.Mock).mockImplementation( + async (params) => { + onCommitmentReachedCallback = params.onCommitmentReached; + return Promise.resolve(); + }, + ); + + mockAnalyticsService = { + trackEventTransactionSubmitted: jest.fn(), + } as unknown as AnalyticsService; + + service = new WalletService( + mockConnection, + mockSigner, + mockSignatureMonitor, + mockAnalyticsService, + logger, + ); + + (globalThis as any).snap = { + request: jest.fn(), + }; + }); + + describe('resolveAccountAddress', () => { + const scope = Network.Testnet; + + it('rejects invalid requests', async () => { + const request = { + id: 1, + jsonrpc: '2.0', + method: 'invalid-method', + params: {}, + } as unknown as SolanaWalletRequest; + + await expect( + service.resolveAccountAddress(mockAccounts, scope, request), + ).rejects.toThrow('Unsupported method'); + }); + + it('handles SolanaSignIn with valid address', async () => { + const request = MOCK_SIGN_IN_REQUEST; + + const result = await service.resolveAccountAddress( + mockAccounts, + scope, + request, + ); + expect(result).toBe(`${scope}:${MOCK_SOLANA_KEYRING_ACCOUNT_2.address}`); + }); + + it('rejects SolanaSignIn without address', async () => { + const request = { + id: 1, + jsonrpc: '2.0', + method: SolMethod.SignIn, + params: {}, + } as unknown as SolanaWalletRequest; + + await expect( + service.resolveAccountAddress(mockAccounts, scope, request), + ).rejects.toThrow('No address'); + }); + + it('handles SolanaSignAndSendTransaction with valid account', async () => { + const request = MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST; + + const result = await service.resolveAccountAddress( + mockAccounts, + scope, + request, + ); + expect(result).toBe(`${scope}:${MOCK_SOLANA_KEYRING_ACCOUNT_1.address}`); + }); + + it('handles SolanaSignMessage with valid account', async () => { + const request = MOCK_SIGN_MESSAGE_REQUEST; + + const result = await service.resolveAccountAddress( + mockAccounts, + scope, + request, + ); + expect(result).toBe(`${scope}:${MOCK_SOLANA_KEYRING_ACCOUNT_3.address}`); + }); + + it('handles SolanaSignTransaction with valid account', async () => { + const request = MOCK_SIGN_TRANSACTION_REQUEST; + + const result = await service.resolveAccountAddress( + mockAccounts, + scope, + request, + ); + expect(result).toBe(`${scope}:${MOCK_SOLANA_KEYRING_ACCOUNT_4.address}`); + }); + + it('rejects request with non-existent account', async () => { + const request = { + ...MOCK_SIGN_TRANSACTION_REQUEST, + params: { + ...MOCK_SIGN_TRANSACTION_REQUEST.params, + account: { + ...MOCK_SIGN_TRANSACTION_REQUEST.params.account, + address: 'non-existent-address', + }, + }, + } as unknown as SolanaWalletRequest; + + await expect( + service.resolveAccountAddress(mockAccounts, scope, request), + ).rejects.toThrow('Account not found'); + }); + + it('rejects when no accounts match scope', async () => { + const request = MOCK_SIGN_TRANSACTION_REQUEST; + + // Set up the keyring so that the account has a different scope + const accounts = [ + { ...MOCK_SOLANA_KEYRING_ACCOUNT_0, scopes: [Network.Mainnet] }, + ]; + + await expect( + service.resolveAccountAddress(accounts, scope, request), + ).rejects.toThrow('No accounts with this scope'); + }); + + it('rejects a SignIn request with an address that does not belong to the keyring accounts', async () => { + const request = { + ...MOCK_SIGN_IN_REQUEST, + params: { + ...MOCK_SIGN_IN_REQUEST.params, + address: 'non-existent-address', + }, + } as unknown as SolanaWalletRequest; + + await expect( + service.resolveAccountAddress(mockAccounts, scope, request), + ).rejects.toThrow('Account not found'); + }); + }); + + describe.each(MOCK_EXECUTION_SCENARIOS)( + 'transaction scenarios', + (scenario) => { + const { + name, + scope, + fromAccount, + transactionMessageBase64Encoded, + signedTransaction, + signedTransactionBase64Encoded, + signature, + getMultipleAccountsResponse, + } = scenario; + + beforeEach(() => { + jest + .spyOn(mockSigner, 'partiallySignBase64String') + .mockResolvedValue(signedTransaction); + + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + ...mockConnection.getRpc(scope), + getMultipleAccounts: jest.fn().mockReturnValue({ + send: jest + .fn() + .mockResolvedValue(getMultipleAccountsResponse?.result), + }), + }); + }); + + describe(`signTransaction`, () => { + it(`Scenario ${name}: returns the signed transaction`, async () => { + const result = await service.signTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + origin, + ); + + expect(result).toStrictEqual({ + signedTransaction: signedTransactionBase64Encoded, + }); + }); + + it('starts monitoring the transaction for commitment "confirmed"', async () => { + await service.signTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + origin, + ); + + expect(mockSignatureMonitor.monitor).toHaveBeenCalledWith( + signature, + fromAccount.id, + 'confirmed', + scope, + 'https://metamask.io', + ); + }); + }); + + describe(`Scenario ${name}: signAndSendTransaction`, () => { + it('returns the signature', async () => { + const result = await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + ); + + expect(result).toStrictEqual({ + signature, + }); + }); + + it('starts monitoring the transaction for commitment "confirmed"', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + {}, + ); + + expect(mockSignatureMonitor.monitor).toHaveBeenCalledWith( + signature, + fromAccount.id, + 'confirmed', + scope, + 'https://metamask.io', + ); + }); + + it('emits Transaction Submitted event after broadcasting', async () => { + await service.signAndSendTransaction( + fromAccount, + transactionMessageBase64Encoded, + scope, + 'https://metamask.io', + ); + + expect( + mockAnalyticsService.trackEventTransactionSubmitted, + ).toHaveBeenCalledWith(fromAccount, signature, { + scope, + origin: 'https://metamask.io', + }); + }); + }); + + describe('signMessage', () => { + it('returns the signed message and is properly verified', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_3; + const { message } = MOCK_SIGN_MESSAGE_REQUEST.params; + + const result = await service.signMessage(account, message); + + expect(result).toStrictEqual(MOCK_SIGN_MESSAGE_RESPONSE); + + const verified = await service.verifySignature( + account, + result.signature, + result.signedMessage, + ); + + expect(verified).toBe(true); + }); + }); + + describe('signIn', () => { + it('returns the signed message', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_2; + const { params } = MOCK_SIGN_IN_REQUEST; + + const result = await service.signIn(account, params); + + expect(result).toStrictEqual(MOCK_SIGN_IN_RESPONSE); + }); + + it('sanitizes control characters from sign-in parameters', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_2; + const maliciousParams = { + domain: 'example.com\n', + address: '5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy\r\n', + statement: 'I accept the terms\n\r\n\nof service', + uri: 'https://example.com/login\r\n', + version: '1\n', + chainId: 'solana:101\r\n', + nonce: '32891756\n', + issuedAt: '2024-01-01T00:00:00.000Z\r\n', + expirationTime: '2024-01-02T00:00:00.000Z\n', + notBefore: '2023-12-31T00:00:00.000Z\r\n', + requestId: '123\n', + resources: [ + 'https://example.com/resource1\r\n', + 'https://example.com/resource2\n', + ], + }; + + const result = await service.signIn(account, maliciousParams); + + // The result should still be valid, but the message will be sanitized + expect(result).toHaveProperty('signature'); + expect(result).toHaveProperty('signedMessage'); + expect(result).toHaveProperty('signatureType', 'ed25519'); + expect(result).toHaveProperty('account'); + }); + + it('handles requests with invalid parameters by sanitizing them', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_2; + const invalidParams = { + domain: '', + address: 'invalid-address', + uri: 'not-a-url', + issuedAt: 'invalid-timestamp', + }; + + // The sanitization should handle invalid parameters gracefully + // and the sign-in should succeed with sanitized values + const result = await service.signIn(account, invalidParams); + + expect(result).toHaveProperty('signature'); + expect(result).toHaveProperty('signedMessage'); + expect(result).toHaveProperty('signatureType', 'ed25519'); + expect(result).toHaveProperty('account'); + }); + }); + + describe('verifySignature', () => { + it('returns true for a valid signature', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_3; + + const result = await service.verifySignature( + account, + MOCK_SIGN_MESSAGE_RESPONSE.signature, + MOCK_SIGN_MESSAGE_RESPONSE.signedMessage, + ); + + expect(result).toBe(true); + }); + }); + }, + ); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts new file mode 100644 index 00000000..a5ebd96b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -0,0 +1,545 @@ +import { SolMethod } from '@metamask/keyring-api'; +import type { Infer } from '@metamask/superstruct'; +import { assert, instance, object } from '@metamask/superstruct'; +import type { Commitment, SignatureBytes } from '@solana/kit'; +import { + address as asAddress, + assertTransactionIsFullySigned, + createKeyPairSignerFromPrivateKeyBytes, + createSignableMessage, + getBase58Codec, + getBase58Decoder, + getBase64Codec, + getSignatureFromTransaction, + getUtf8Codec, + sendTransactionWithoutConfirmingFactory, + verifySignature, +} from '@solana/kit'; + +import type { SolanaKeyringAccount } from '../../../entities'; +import type { Caip10Address, Network } from '../../constants/solana'; +import type { DecompileTransactionMessageFetchingLookupTablesConfig } from '../../sdk-extensions/codecs'; +import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; +import { addressToCaip10 } from '../../utils/addressToCaip10'; +import { deriveSolanaKeypair } from '../../utils/deriveSolanaKeypair'; +import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; +import type { ILogger } from '../../utils/logger'; +import logger, { createPrefixedLogger } from '../../utils/logger'; +import { Base58Struct, Base64Struct } from '../../validation/structs'; +import type { AnalyticsService } from '../analytics/AnalyticsService'; +import type { SolanaConnection } from '../connection'; +import type { Signer } from '../signer/Signer'; +import type { SignatureMonitor } from '../subscriptions'; +import { + SolanaSignAndSendTransactionResponseStruct, + SolanaSignInResponseStruct, + SolanaSignMessageResponseStruct, + SolanaSignTransactionResponseStruct, +} from './structs'; +import type { + SolanaSignAndSendTransactionOptions, + SolanaSignAndSendTransactionResponse, + SolanaSignInRequest, + SolanaSignTransactionOptions, + SolanaWalletRequest, +} from './structs'; +import type { + SolanaSignInResponse, + SolanaSignMessageResponse, + SolanaSignTransactionResponse, +} from './structs'; + +export class WalletService { + readonly #connection: SolanaConnection; + + readonly #signer: Signer; + + readonly #signatureMonitor: SignatureMonitor; + + readonly #analyticsService: AnalyticsService; + + readonly #logger: ILogger; + + constructor( + connection: SolanaConnection, + signer: Signer, + signatureMonitor: SignatureMonitor, + analyticsService: AnalyticsService, + _logger = logger, + ) { + this.#connection = connection; + this.#signer = signer; + this.#signatureMonitor = signatureMonitor; + this.#analyticsService = analyticsService; + this.#logger = createPrefixedLogger(_logger, '[👛 WalletService]'); + } + + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param keyringAccounts - The accounts available in the keyring. + * @param scope - Request's scope (CAIP-2). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address that must + * be used to process this signing request, or null if none candidates + * could be found. + * @throws If the request is invalid. + */ + async resolveAccountAddress( + keyringAccounts: SolanaKeyringAccount[], + scope: Network, + request: SolanaWalletRequest, + ): Promise { + this.#logger.log('Resolving account address', { + keyringAccounts, + scope, + request, + }); + + const { method, params } = request; + + const accountsWithThisScope = keyringAccounts.filter((account) => + account.scopes.includes(scope), + ); + + if (accountsWithThisScope.length === 0) { + throw new Error('No accounts with this scope'); + } + + let addressToValidate: string; + + switch (method) { + case SolMethod.SignIn: { + const { address } = params; + if (!address) { + throw new Error('No address'); + } + addressToValidate = address; + break; + } + case SolMethod.SignAndSendTransaction: + case SolMethod.SignMessage: + case SolMethod.SignTransaction: { + const { account } = params; + addressToValidate = account.address; + break; + } + default: { + // This code is unreachable because the "validateRequest" function + // already protects against invalid methods. + this.#logger.warn({ method }, 'Unsupported method'); + throw new Error('Unsupported method'); + } + } + + const foundAccount = accountsWithThisScope.find( + (a) => a.address === addressToValidate, + ); + + if (!foundAccount) { + throw new Error('Account not found'); + } + + return addressToCaip10(scope, addressToValidate); + } + + /** + * Signs a transaction. + * + * For a detailed visual representation of the transaction signing flow, see the + * [transaction signing flow diagram](./img/transaction-signing-flow.png). + * + * @param account - The account to sign the transaction. + * @param transaction - The transaction to sign. + * @param scope - The scope of the transaction. + * @param origin - The origin of the transaction. + * @param options - The options for the transaction. + * @returns A Promise that resolves to the signed transaction. + */ + async signTransaction( + account: SolanaKeyringAccount, + transaction: string, + scope: Network, + origin: string, + options?: SolanaSignTransactionOptions, + ): Promise { + this.#logger.log('Signing transaction', { + account, + transaction, + scope, + options, + }); + + const config: DecompileTransactionMessageFetchingLookupTablesConfig = + options?.minContextSlot + ? { + minContextSlot: BigInt(options.minContextSlot), + } + : undefined; + + const partiallySignedTransaction = + await this.#signer.partiallySignBase64String( + transaction, + account, + scope, + config, + ); + + const signedTransactionBase64 = fromTransactionToBase64String( + partiallySignedTransaction, + ); + + const result = { + signedTransaction: signedTransactionBase64, + }; + + assert(result, SolanaSignTransactionResponseStruct); + + // If the transaction is fully signed, we can monitor it. + try { + assertTransactionIsFullySigned(partiallySignedTransaction); + const signature = getSignatureFromTransaction(partiallySignedTransaction); + await this.#signatureMonitor.monitor( + signature, + account.id, + 'confirmed', + scope, + origin, + ); + } catch (error) { + this.#logger.warn( + 'Transaction is not fully signed, skipping monitoring', + { + error, + }, + ); + } + + return result; + } + + /** + * Signs and sends a transaction. + * + * @param account - The account to sign and send the transaction. + * @param transactionMessageBase64Encoded - The transaction message base64 encoded. + * @param scope - The scope of the transaction. + * @param origin - The origin of the transaction. + * @param options - The options for the transaction. + * @param options.minContextSlot - The minimum context slot. + * @param options.preflightCommitment - The preflight commitment. + * @param options.maxRetries - The maximum number of retries. + * @param options.commitment - The commitment. + * @returns A Promise that resolves to the signed transaction. + */ + async signAndSendTransaction( + account: SolanaKeyringAccount, + transactionMessageBase64Encoded: string, + scope: Network, + origin: string, + options?: SolanaSignAndSendTransactionOptions, + ): Promise { + this.#logger.log('Signing and sending transaction', account); + + const signConfig: DecompileTransactionMessageFetchingLookupTablesConfig = + options?.minContextSlot + ? { + minContextSlot: BigInt(options.minContextSlot), + } + : undefined; + + const partiallySignedTransaction = + await this.#signer.partiallySignBase64String( + transactionMessageBase64Encoded, + account, + scope, + signConfig, + ); + + const signature = getSignatureFromTransaction(partiallySignedTransaction); + + const rpc = this.#connection.getRpc(scope); + + const sendTransactionWithoutConfirming = + sendTransactionWithoutConfirmingFactory({ + rpc, + }); + + const explorerUrl = getSolanaExplorerUrl(scope, 'tx', signature); + this.#logger.info(`Sending transaction: ${explorerUrl}`); + + assertTransactionIsFullySigned(partiallySignedTransaction); + + const sendConfig = { + ...(options?.preflightCommitment + ? { preflightCommitment: options.preflightCommitment } + : {}), + ...(options?.minContextSlot + ? { minContextSlot: BigInt(options.minContextSlot) } + : {}), + ...(options?.maxRetries + ? { maxRetries: BigInt(options.maxRetries) } + : {}), + skipPreflight: options?.skipPreflight ?? true, + // Set to 'confirmed' as required to be defined, but ignored by sendTransactionWithoutConfirming. + // This is because RPC Subscriptions rely on websockets, which are unavailable in the Snap environment. + // We compensate for this with `waitForTransactionCommitment`. + commitment: 'confirmed' as Commitment, + }; + + await sendTransactionWithoutConfirming( + partiallySignedTransaction, + sendConfig, + ); + + await this.#analyticsService.trackEventTransactionSubmitted( + account, + signature, + { scope, origin }, + ); + + await this.#signatureMonitor.monitor( + signature, + account.id, + options?.commitment ?? 'confirmed', + scope, + origin, + ); + + const result = { + signature, + }; + assert(result, SolanaSignAndSendTransactionResponseStruct); + return result; + } + + /** + * Signs the provided base64 encoded message using the provided account's + * private key. + * + * It DOES NOT decode the message to UTF-8 before signing, meaning that the + * signature must be verified using the base64 encoded message as well. + * + * You can then verify the signature with {@link WalletService.verifySignature}. + * + * @param account - The account to sign the message. + * @param message - The message to sign. + * @returns A Promise that resolves to the signed message. + */ + async signMessage( + account: SolanaKeyringAccount, + message: string, + ): Promise { + this.#logger.log('Signing message', account, message); + + const { address, entropySource, derivationPath } = account; + const addressAsAddress = asAddress(address); + const messageBytes = getBase64Codec().encode(message); + const messageUtf8 = getUtf8Codec().decode(messageBytes); + const signableMessage = createSignableMessage(messageUtf8); + + const { privateKeyBytes } = await deriveSolanaKeypair({ + entropySource, + derivationPath, + }); + + const signer = + await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); + + const [messageSignatureBytesMap] = await signer.signMessages([ + signableMessage, + ]); + + // Equivalent to - but more compact than - an undefined check + throw error + assert(messageSignatureBytesMap, object()); + + const messageSignatureBytes = messageSignatureBytesMap[addressAsAddress]; + + // Equivalent to - but more compact than - an undefined check + throw error + assert(messageSignatureBytes, instance(Uint8Array)); + + const signature = getBase58Decoder().decode(messageSignatureBytes); + + const result = { + signature, + signedMessage: message, + signatureType: 'ed25519', + }; + + assert(result, SolanaSignMessageResponseStruct); + + return result; + } + + /** + * Signs in to the Solana blockchain. Receives a sign in intent object + * that contains data like domain, or uri, then converts it into a message + * using `JSON.stringify()`, then signs the message. + * + * @param account - The account to sign the message. + * @param params - A sign in intent object that contains data like domain, or uri. + * @returns A Promise that resolves to the signed message. + * @throws If the request is invalid. + */ + async signIn( + account: SolanaKeyringAccount, + params: SolanaSignInRequest['params'], + ): Promise { + this.#logger.log('Signing in', account, params); + + const { address } = account; + const messageUtf8 = this.#formatSignInMessage(params); + const messageBytes = getUtf8Codec().encode(messageUtf8); + const messageBase64 = getBase64Codec().decode(messageBytes); + + const signMessageResponse = await this.signMessage(account, messageBase64); + + const result = { + account: { + address, + }, + ...signMessageResponse, + }; + + assert(result, SolanaSignInResponseStruct); + + return result; + } + + /** + * Verifies that the passed signature was rightfully created by signing the + * passed message with the passed account's private key. + * + * @param account - The account that is being verified. + * @param signatureBase58 - The signature to verify. + * @param messageBase64 - The original message. + * @returns A Promise that resolves to a boolean indicating whether the + * signature is valid. + */ + async verifySignature( + account: SolanaKeyringAccount, + signatureBase58: Infer, + messageBase64: Infer, + ): Promise { + this.#logger.log('Verifying signature', { + account, + signatureBase58, + messageBase64, + }); + + assert(signatureBase58, Base58Struct); + assert(messageBase64, Base64Struct); + + const signatureBytes = getBase58Codec().encode( + signatureBase58, + ) as SignatureBytes; + const messageBytes = getBase64Codec().encode(messageBase64); + + const { privateKeyBytes } = await deriveSolanaKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const signer = + await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); + + const verified = await verifySignature( + signer.keyPair.publicKey, + signatureBytes, + messageBytes, + ); + + return verified; + } + + /** + * Formats a Solana Sign-In message into a string. + * + * @param signInParams - The sign-in message parameters. + * @returns The formatted message as a string. + */ + #formatSignInMessage(signInParams: SolanaSignInRequest['params']): string { + // ${domain} wants you to sign in with your Solana account: + // ${address} + // + // ${statement} + // + // URI: ${uri} + // Version: ${version} + // Chain ID: ${chain} + // Nonce: ${nonce} + // Issued At: ${issued-at} + // Expiration Time: ${expiration-time} + // Not Before: ${not-before} + // Request ID: ${request-id} + // Resources: + // - ${resources[0]} + // - ${resources[1]} + // ... + // - ${resources[n]} + + const { + domain, + address, + statement, + uri, + version, + chainId, + nonce, + issuedAt, + expirationTime, + notBefore, + requestId, + resources, + } = signInParams; + + // The inputs are already sanitized by the struct validation + // So there is no need to sanitize again here + let message = `${domain ?? ''} wants you to sign in with your Solana account:\n`; + message += `${address ?? ''}`; + + if (statement) { + message += `\n\n${statement}`; + } + + const fields: string[] = []; + if (uri) { + fields.push(`URI: ${uri}`); + } + if (version) { + fields.push(`Version: ${version}`); + } + if (chainId) { + fields.push(`Chain ID: ${chainId}`); + } + if (nonce) { + fields.push(`Nonce: ${nonce}`); + } + if (issuedAt) { + fields.push(`Issued At: ${issuedAt}`); + } + if (expirationTime) { + fields.push(`Expiration Time: ${expirationTime}`); + } + if (notBefore) { + fields.push(`Not Before: ${notBefore}`); + } + if (requestId) { + fields.push(`Request ID: ${requestId}`); + } + if (resources && resources.length > 0) { + fields.push(`Resources:`); + for (const resource of resources) { + fields.push(`- ${resource}`); + } + } + if (fields.length) { + message += `\n\n${fields.join('\n')}`; + } + + return message; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/img/transaction-signing-flow.png b/merged-packages/solana-wallet-snap/src/core/services/wallet/img/transaction-signing-flow.png new file mode 100644 index 00000000..199f15df Binary files /dev/null and b/merged-packages/solana-wallet-snap/src/core/services/wallet/img/transaction-signing-flow.png differ diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/mocks.ts b/merged-packages/solana-wallet-snap/src/core/services/wallet/mocks.ts new file mode 100644 index 00000000..96a9123d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/wallet/mocks.ts @@ -0,0 +1,153 @@ +import { KeyringRpcMethod, SolMethod } from '@metamask/keyring-api'; +import type { ResolveAccountAddressRequest } from '@metamask/keyring-api'; +import type { KeyringRequest } from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { Network } from '../../constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, +} from '../../test/mocks/solana-keyring-accounts'; +import { MOCK_EXECUTION_SCENARIO_SEND_SOL } from '../signer/mocks/scenarios/sendSol'; +import type { + SolanaSignAndSendTransactionRequest, + SolanaSignAndSendTransactionResponse, + SolanaSignInRequest, + SolanaSignInResponse, + SolanaSignMessageRequest, + SolanaSignMessageResponse, + SolanaSignTransactionRequest, + SolanaSignTransactionResponse, +} from './structs'; + +export const wrapKeyringRequest = ( + request: Request, +): KeyringRequest => + ({ + id: '1', + request, + account: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + scope: Network.Localnet, + origin: 'https://metamask.io', + }) as const; + +/** + * signAndSendTransaction + */ + +export const MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST: SolanaSignAndSendTransactionRequest = + { + method: SolMethod.SignAndSendTransaction, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + }, + transaction: + MOCK_EXECUTION_SCENARIO_SEND_SOL.transactionMessageBase64Encoded, + scope: Network.Localnet, + }, + } as const; + +export const MOCK_SIGN_AND_SEND_TRANSACTION_RESPONSE: SolanaSignAndSendTransactionResponse = + { + signature: MOCK_EXECUTION_SCENARIO_SEND_SOL.signature, + } as const; + +/** + * signTransaction + */ + +export const MOCK_SIGN_TRANSACTION_REQUEST: SolanaSignTransactionRequest = { + method: SolMethod.SignTransaction, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_4.address, + }, + transaction: + MOCK_EXECUTION_SCENARIO_SEND_SOL.transactionMessageBase64Encoded, + scope: Network.Localnet, + }, +}; + +export const MOCK_SIGN_TRANSACTION_RESPONSE: SolanaSignTransactionResponse = { + signedTransaction: + MOCK_EXECUTION_SCENARIO_SEND_SOL.signedTransactionBase64Encoded, +} as const; + +/** + * signMessage + */ + +export const MOCK_SIGN_MESSAGE_REQUEST: SolanaSignMessageRequest = { + method: SolMethod.SignMessage, + params: { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_3.address, + }, + message: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + }, +}; + +export const MOCK_SIGN_MESSAGE_RESPONSE: SolanaSignMessageResponse = { + signature: + '2n1rfebBmxvRd6MMdDdV5V9Hyy34FRBgVc6EFGjH78fNUW2Fz6RgkMwpHwLGFVQS2BBDkHV38FuKdavSF2GTo5gq', // When signed by MOCK_SOLANA_KEYRING_ACCOUNT_3 + signedMessage: 'SGVsbG8sIHdvcmxkIQ==', // "Hello, world!" in base64 + signatureType: 'ed25519', +} as const; + +/** + * signIn + */ + +export const MOCK_SIGN_IN_REQUEST: SolanaSignInRequest = { + method: SolMethod.SignIn, + params: { + domain: 'example.com', + address: MOCK_SOLANA_KEYRING_ACCOUNT_2.address, + statement: 'I accept the terms of service', + uri: 'https://example.com/login', + version: '1', + chainId: 'solana:101', + nonce: '32891756', + issuedAt: '2024-01-01T00:00:00.000Z', + expirationTime: '2024-01-02T00:00:00.000Z', + notBefore: '2023-12-31T00:00:00.000Z', + requestId: '123', + resources: [ + 'ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/', + 'https://example.com/my-web2-claim.json', + ], + }, +} as const; + +export const MOCK_SIGN_IN_RESPONSE: SolanaSignInResponse = { + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_2.address, + }, + signature: + '3SNumB7EXxEw8DbEXjYDKYMZRdGQb8CCrUL5cheTtkHk3arZA7mpvPw7PRT8vYMHzkmcPfrmdar1hofvFmb42rxS', // When signed by MOCK_SOLANA_KEYRING_ACCOUNT_2 + signedMessage: + 'ZXhhbXBsZS5jb20gd2FudHMgeW91IHRvIHNpZ24gaW4gd2l0aCB5b3VyIFNvbGFuYSBhY2NvdW50OgoyN2g2Y202UzlhZzV5NEFTaTFhMXZiVFNLRXNRTWpFZGZ2WjZhdFBqbWJ1RAoKSSBhY2NlcHQgdGhlIHRlcm1zIG9mIHNlcnZpY2UKClVSSTogaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dpbgpWZXJzaW9uOiAxCkNoYWluIElEOiBzb2xhbmE6MTAxCk5vbmNlOiAzMjg5MTc1NgpJc3N1ZWQgQXQ6IDIwMjQtMDEtMDFUMDA6MDA6MDAuMDAwWgpFeHBpcmF0aW9uIFRpbWU6IDIwMjQtMDEtMDJUMDA6MDA6MDAuMDAwWgpOb3QgQmVmb3JlOiAyMDIzLTEyLTMxVDAwOjAwOjAwLjAwMFoKUmVxdWVzdCBJRDogMTIzClJlc291cmNlczoKLSBpcGZzOi8vYmFmeWJlaWVteGY1YWJqd2piaWtvejRtYzNhM2RsYTZ1YWwzanNncGRyNGNqcjNvejNldmZ5YXZod3EvCi0gaHR0cHM6Ly9leGFtcGxlLmNvbS9teS13ZWIyLWNsYWltLmpzb24=', // MOCK_SIGN_IN_REQUEST.params that was formatted, then base64 encoded + signatureType: 'ed25519', +} as const; + +/** + * resolveAccountAddress + */ + +export const MOCK_RESOLVE_ACCOUNT_ADDRESS_REQUEST: ResolveAccountAddressRequest = + { + id: 1, + jsonrpc: '2.0', + method: KeyringRpcMethod.ResolveAccountAddress, + params: { + request: { + id: 1, + jsonrpc: '2.0', + ...MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST, + } as unknown as JsonRpcRequest, + scope: Network.Testnet, + }, + }; diff --git a/merged-packages/solana-wallet-snap/src/core/services/wallet/structs.ts b/merged-packages/solana-wallet-snap/src/core/services/wallet/structs.ts new file mode 100644 index 00000000..58e5ec5c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/services/wallet/structs.ts @@ -0,0 +1,270 @@ +import { SolMethod } from '@metamask/keyring-api'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + coerce, + enums, + literal, + number, + object, + optional, + string, + type, + union, +} from '@metamask/superstruct'; + +import { Network } from '../../constants/solana'; +import { + sanitizeDomain, + sanitizeForSignInMessage, + sanitizeResources, + sanitizeSolanaAddress, + sanitizeTimestamp, + sanitizeUri, +} from '../../utils/sanitize'; +import { Base58Struct, Base64Struct } from '../../validation/structs'; + +/** + * Defines all structs derived from types defined in the Solana Wallet Standard. + * Unfortunately the structs cannot be derived automatically from the types, so we need to manually define them. + * + * This will be used to validate incoming JSON-RPC requests that follow the Solana Wallet Standard. + * + * @see https://github.com/anza-xyz/wallet-standard/tree/master/packages/core/features/src + */ + +export const ScopeStringStruct = enums(Object.values(Network)); + +// Sanitizing structs that transform values during validation +const SanitizedSolanaAddressStruct = coerce( + string(), + string(), + (value: string) => { + if (!value || value === '') { + throw new Error('Account address is required'); + } + const sanitized = sanitizeSolanaAddress(value); + if (sanitized === '') { + throw new Error('Invalid Solana address format'); + } + return sanitized; + }, +); + +const WalletAccountStruct = type({ + address: SanitizedSolanaAddressStruct, +}); + +const SolanaSignatureTypeStruct = literal('ed25519'); + +// Sanitizing structs for SIWS fields +const SanitizedDomainStruct = coerce(string(), string(), (value: string) => { + const sanitized = sanitizeDomain(value); + if (sanitized === '') { + throw new Error('Invalid domain format'); + } + return sanitized; +}); + +const SanitizedAddressStruct = coerce(string(), string(), (value: string) => { + const sanitized = sanitizeSolanaAddress(value); + if (sanitized === '') { + throw new Error('Invalid Solana address format'); + } + return sanitized; +}); + +const SanitizedStatementStruct = coerce(string(), string(), (value: string) => { + const sanitized = sanitizeForSignInMessage(value, 1000); + if (sanitized === '') { + throw new Error( + 'Statement cannot be empty. Control characters and excessive whitespace are removed for security. Please use plain text only.', + ); + } + return sanitized; +}); + +const SanitizedUriStruct = coerce(string(), string(), (value: string) => { + return sanitizeUri(value); +}); + +const SanitizedVersionStruct = coerce(string(), string(), (value: string) => { + return sanitizeForSignInMessage(value, 10); +}); + +const SanitizedChainIdStruct = coerce(string(), string(), (value: string) => { + return sanitizeForSignInMessage(value, 50); +}); + +const SanitizedNonceStruct = coerce(string(), string(), (value: string) => { + return sanitizeForSignInMessage(value, 100); +}); + +const SanitizedTimestampStruct = coerce(string(), string(), (value: string) => { + return sanitizeTimestamp(value); +}); + +const SanitizedRequestIdStruct = coerce(string(), string(), (value: string) => { + return sanitizeForSignInMessage(value, 100); +}); + +const SanitizedResourcesStruct = coerce( + array(string()), + array(string()), + (value: string[]) => { + const sanitized = sanitizeResources(value); + + return sanitized; + }, +); + +const SolanaSignInInputStruct = type({ + domain: optional(SanitizedDomainStruct), + address: optional(SanitizedAddressStruct), + statement: optional(SanitizedStatementStruct), + uri: optional(SanitizedUriStruct), + version: optional(SanitizedVersionStruct), + chainId: optional(SanitizedChainIdStruct), + nonce: optional(SanitizedNonceStruct), + issuedAt: optional(SanitizedTimestampStruct), + expirationTime: optional(SanitizedTimestampStruct), + notBefore: optional(SanitizedTimestampStruct), + requestId: optional(SanitizedRequestIdStruct), + resources: optional(SanitizedResourcesStruct), +}); + +const SolanaSignMessageInputStruct = type({ + account: WalletAccountStruct, + message: Base64Struct, +}); + +const SolanaTransactionCommitmentStruct = enums([ + 'processed', + 'confirmed', + 'finalized', +]); + +const SolanaSignTransactionOptionsStruct = type({ + /** Preflight commitment level. */ + preflightCommitment: optional(SolanaTransactionCommitmentStruct), + /** The minimum slot that the request can be evaluated at. */ + minContextSlot: optional(number()), +}); + +export type SolanaSignTransactionOptions = Infer< + typeof SolanaSignTransactionOptionsStruct +>; + +const SolanaSignTransactionInputStruct = type({ + account: WalletAccountStruct, + transaction: Base64Struct, + scope: ScopeStringStruct, + options: optional(SolanaSignTransactionOptionsStruct), +}); + +export const SolanaSignAndSendTransactionOptionsStruct = type({ + ...SolanaSignTransactionOptionsStruct.schema, + /** Desired commitment level. If provided, confirm the transaction after sending. */ + commitment: optional(SolanaTransactionCommitmentStruct), + /** Disable transaction verification at the RPC. */ + skipPreflight: optional(boolean()), + /** Maximum number of times for the RPC node to retry sending the transaction to the leader. */ + maxRetries: optional(number()), +}); + +export type SolanaSignAndSendTransactionOptions = Infer< + typeof SolanaSignAndSendTransactionOptionsStruct +>; + +export const SolanaSignAndSendTransactionInputStruct = type({ + ...SolanaSignTransactionInputStruct.schema, + scope: ScopeStringStruct, + options: optional(SolanaSignAndSendTransactionOptionsStruct), +}); + +export const SolanaSignAndSendTransactionRequestStruct = object({ + method: enums([SolMethod.SignAndSendTransaction]), + params: SolanaSignAndSendTransactionInputStruct, +}); + +export const SolanaSignInRequestStruct = object({ + method: enums([SolMethod.SignIn]), + params: SolanaSignInInputStruct, +}); + +export const SolanaSignMessageRequestStruct = object({ + method: enums([SolMethod.SignMessage]), + params: SolanaSignMessageInputStruct, +}); + +export const SolanaSignTransactionRequestStruct = object({ + method: enums([SolMethod.SignTransaction]), + params: SolanaSignTransactionInputStruct, +}); + +export type SolanaSignAndSendTransactionRequest = Infer< + typeof SolanaSignAndSendTransactionRequestStruct +>; + +export type SolanaSignInRequest = Infer; + +export type SolanaSignMessageRequest = Infer< + typeof SolanaSignMessageRequestStruct +>; + +export type SolanaSignTransactionRequest = Infer< + typeof SolanaSignTransactionRequestStruct +>; + +export const SolanaSignAndSendTransactionResponseStruct = object({ + signature: Base58Struct, +}); + +export type SolanaSignAndSendTransactionResponse = Infer< + typeof SolanaSignAndSendTransactionResponseStruct +>; + +export const SolanaSignTransactionResponseStruct = object({ + /** + * The whole signed transaction object, encoded in base64. It is NOT the signature. + * Returning a transaction rather than signatures allows multisig wallets, program wallets, and other wallets that + * use meta-transactions to return a modified, signed transaction. + */ + signedTransaction: Base64Struct, +}); + +export type SolanaSignTransactionResponse = Infer< + typeof SolanaSignTransactionResponseStruct +>; + +export const SolanaSignMessageResponseStruct = object({ + signature: Base58Struct, + signedMessage: Base64Struct, + signatureType: SolanaSignatureTypeStruct, +}); + +export type SolanaSignMessageResponse = Infer< + typeof SolanaSignMessageResponseStruct +>; + +export const SolanaSignInResponseStruct = object({ + account: WalletAccountStruct, + ...SolanaSignMessageResponseStruct.schema, +}); + +export type SolanaSignInResponse = Infer; + +/** + * Validates that a JsonRpcRequest is a valid Solana request. + * + * @see https://github.com/MetaMask/accounts/blob/main/packages/keyring-api/docs/sol-methods.md + */ +export const SolanaWalletRequestStruct = union([ + SolanaSignAndSendTransactionRequestStruct, + SolanaSignInRequestStruct, + SolanaSignMessageRequestStruct, + SolanaSignTransactionRequestStruct, +]); + +export type SolanaWalletRequest = Infer; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/asset-entities.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/asset-entities.ts new file mode 100644 index 00000000..e6286204 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/asset-entities.ts @@ -0,0 +1,92 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; + +import type { AssetEntity, NativeAsset, TokenAsset } from '../../../entities'; +import { KnownCaip19Id, Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from './solana-keyring-accounts'; + +export const MOCK_ASSET_ENTITY_0: NativeAsset = { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', +}; + +export const MOCK_ASSET_ENTITY_1: TokenAsset = { + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + symbol: 'USDC', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + decimals: 6, + rawAmount: '123456789', + uiAmount: '123.456789', +}; + +export const MOCK_ASSET_ENTITY_2: TokenAsset = { + assetType: KnownCaip19Id.Ai16zMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + symbol: 'AI16Z', + mint: 'HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC', + pubkey: 'DJGpJufSnVDriDczovhcQRyxamKtt87PHQ7TJEcVB6ta', + decimals: 9, + rawAmount: '987654321', + uiAmount: '987.654321', +}; + +export const MOCK_ASSET_ENTITIES: AssetEntity[] = [ + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, +]; + +export const SOLANA_MOCK_TOKEN_METADATA: Record = + { + [KnownCaip19Id.SolLocalnet]: { + iconUrl: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/sol/logo.png', + name: 'Solana', + symbol: 'SOL', + fungible: true, + units: [ + { + decimals: 9, + name: 'Solana', + symbol: 'SOL', + }, + ], + }, + [KnownCaip19Id.UsdcMainnet]: { + iconUrl: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', + name: 'USDC', + symbol: 'USDC', + fungible: true, + units: [ + { + decimals: 6, + name: 'USDC', + symbol: 'USDC', + }, + ], + }, + [KnownCaip19Id.Ai16zMainnet]: { + iconUrl: + 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC/logo.png', + name: 'ai16z', + symbol: 'AI16Z', + fungible: true, + units: [ + { + decimals: 9, + name: 'ai16z', + symbol: 'AI16Z', + }, + ], + }, + }; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/market-data.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/market-data.ts new file mode 100644 index 00000000..3cbde9ff --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/market-data.ts @@ -0,0 +1,243 @@ +import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; + +// Common asset types for testing +export const TEST_ASSET_TYPES = { + BTC: 'bip122:000000000019d6689c085ae165831e93/slip44:0' as CaipAssetType, + ETH: 'eip155:1/slip44:60' as CaipAssetType, + SOL: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, + USDC: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + USD: 'swift:0/iso4217:USD' as CaipAssetType, + EUR: 'swift:0/iso4217:EUR' as CaipAssetType, + GBP: 'swift:0/iso4217:GBP' as CaipAssetType, + SPECIAL_SOL: + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501' as CaipAssetType, +} as const; + +// ISO 8601 duration constants +export const ISO_DURATIONS = { + PT1H: 'PT1H', + P1D: 'P1D', + P7D: 'P7D', + P14D: 'P14D', + P30D: 'P30D', + P200D: 'P200D', + P1Y: 'P1Y', +} as const; + +// Mock market data for different scenarios +export const MOCK_MARKET_DATA: Record = + { + [TEST_ASSET_TYPES.BTC]: { + fungible: true, + marketCap: '1000000000000', + totalVolume: '50000000000', + circulatingSupply: '19500000', + allTimeHigh: '120000', + allTimeLow: '67.81', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 0.5, + [ISO_DURATIONS.P1D]: 2.1, + [ISO_DURATIONS.P7D]: -1.2, + [ISO_DURATIONS.P30D]: 15.3, + [ISO_DURATIONS.P200D]: 45.1, + [ISO_DURATIONS.P1Y]: 21.4, + }, + }, + [TEST_ASSET_TYPES.ETH]: { + fungible: true, + marketCap: '400000000000', + totalVolume: '20000000000', + circulatingSupply: '120000000', + allTimeHigh: '5000', + allTimeLow: '0.43', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 1.2, + [ISO_DURATIONS.P1D]: 3.5, + [ISO_DURATIONS.P7D]: 5.1, + [ISO_DURATIONS.P30D]: 8.7, + }, + }, + [TEST_ASSET_TYPES.SOL]: { + fungible: true, + marketCap: '80000000000', + totalVolume: '3000000000', + circulatingSupply: '400000000', + allTimeHigh: '260', + allTimeLow: '0.5', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: -0.8, + [ISO_DURATIONS.P1D]: 1.5, + [ISO_DURATIONS.P7D]: -2.3, + }, + }, + [TEST_ASSET_TYPES.USDC]: { + fungible: true, + marketCap: '25000000000', + totalVolume: '1500000000', + circulatingSupply: '25000000000', + allTimeHigh: '1.05', + allTimeLow: '0.95', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 0.01, + [ISO_DURATIONS.P1D]: 0.02, + [ISO_DURATIONS.P7D]: 0.05, + }, + }, + [TEST_ASSET_TYPES.SPECIAL_SOL]: { + fungible: true, + marketCap: '50000000', + totalVolume: '2000000', + circulatingSupply: '1000000', + }, + }; + +// Mock market data for different currencies +export const MOCK_MARKET_DATA_EUR: Record< + CaipAssetType, + FungibleAssetMarketData +> = { + [TEST_ASSET_TYPES.BTC]: { + fungible: true, + marketCap: '850000000000', + totalVolume: '42500000000', + circulatingSupply: '19500000', + allTimeHigh: '102000', + allTimeLow: '57.64', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 0.3, + [ISO_DURATIONS.P1D]: 1.8, + }, + }, + [TEST_ASSET_TYPES.ETH]: { + fungible: true, + marketCap: '340000000000', + totalVolume: '17000000000', + circulatingSupply: '120000000', + allTimeHigh: '4250', + allTimeLow: '0.37', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 1.0, + [ISO_DURATIONS.P1D]: 3.0, + }, + }, +}; + +// Mock market data with minimal fields +export const MOCK_MARKET_DATA_MINIMAL: Record< + CaipAssetType, + FungibleAssetMarketData +> = { + [TEST_ASSET_TYPES.BTC]: { + fungible: true, + marketCap: '1000000000000', + // Missing other fields + }, + [TEST_ASSET_TYPES.ETH]: { + fungible: true, + // Only fungible field + }, +}; + +// Mock market data with only price percent changes +export const MOCK_MARKET_DATA_PRICE_CHANGES_ONLY: Record< + CaipAssetType, + FungibleAssetMarketData +> = { + [TEST_ASSET_TYPES.BTC]: { + fungible: true, + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 0.5, + [ISO_DURATIONS.P1D]: 2.1, + [ISO_DURATIONS.P7D]: -1.2, + }, + }, +}; + +// Mock market data with zero values +export const MOCK_MARKET_DATA_ZERO_VALUES: Record< + CaipAssetType, + FungibleAssetMarketData +> = { + [TEST_ASSET_TYPES.ETH]: { + fungible: true, + marketCap: '0', + totalVolume: '0', + circulatingSupply: '0', + allTimeHigh: '0', + allTimeLow: '0', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 0, + [ISO_DURATIONS.P1D]: 0, + [ISO_DURATIONS.P7D]: 0, + }, + }, +}; + +// Mock market data with very large numbers +export const MOCK_MARKET_DATA_LARGE_NUMBERS: Record< + CaipAssetType, + FungibleAssetMarketData +> = { + [TEST_ASSET_TYPES.BTC]: { + fungible: true, + marketCap: '999999999999999999999999999999', + totalVolume: '123456789012345678901234567890', + circulatingSupply: '21000000', + allTimeHigh: '999999999999999999999999999999', + allTimeLow: '0.000000000000000001', + pricePercentChange: { + [ISO_DURATIONS.PT1H]: 999.99, + [ISO_DURATIONS.P1D]: -999.99, + }, + }, +}; + +// Mock asset request parameters +export const MOCK_ASSET_REQUESTS = { + SINGLE_BTC_USD: [{ asset: TEST_ASSET_TYPES.BTC, unit: TEST_ASSET_TYPES.USD }], + MULTIPLE_CRYPTO_USD: [ + { asset: TEST_ASSET_TYPES.BTC, unit: TEST_ASSET_TYPES.USD }, + { asset: TEST_ASSET_TYPES.ETH, unit: TEST_ASSET_TYPES.USD }, + { asset: TEST_ASSET_TYPES.SOL, unit: TEST_ASSET_TYPES.USD }, + ], + MIXED_CURRENCIES: [ + { asset: TEST_ASSET_TYPES.BTC, unit: TEST_ASSET_TYPES.EUR }, + { asset: TEST_ASSET_TYPES.ETH, unit: TEST_ASSET_TYPES.USD }, + ], + EMPTY: [], + SPECIAL_CHARACTERS: [ + { asset: TEST_ASSET_TYPES.SPECIAL_SOL, unit: TEST_ASSET_TYPES.USD }, + ], +} as const; + +// Helper function to create mock market data for specific assets +export const createMockMarketData = ( + assets: CaipAssetType[], + dataSource: Record = MOCK_MARKET_DATA, +): Record => { + const result: Record = {}; + + for (const asset of assets) { + if (dataSource[asset]) { + result[asset] = dataSource[asset]; + } + } + + return result; +}; + +// Helper function to create mock asset requests +export const createMockAssetRequest = ( + assets: { asset: CaipAssetType; unit: CaipAssetType }[], +) => ({ + assets, +}); + +// Mock error scenarios +export const MOCK_ERRORS = { + NETWORK_TIMEOUT: new Error('Network timeout'), + INVALID_ASSET_TYPE: new Error('Invalid asset type'), + SERVICE_UNAVAILABLE: new Error('Service unavailable'), + RATE_LIMIT_EXCEEDED: new Error('Rate limit exceeded'), +} as const; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/exchange-rates.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/exchange-rates.ts new file mode 100644 index 00000000..8bb336df --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/exchange-rates.ts @@ -0,0 +1,476 @@ +import type { ExchangeRate, Ticker } from '../../../clients/price-api/types'; + +/** + * HEADS UP! Changing this mock MUST involve changing the spot prices mock too! + * Their values are interdependent and essential for the TokenPricesService tests. + */ +export const MOCK_EXCHANGE_RATES: Record = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000009225522122806664, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.0004032198954215109, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.011656225789635273, + currencyType: 'crypto', + }, + bch: { + name: 'Bitcoin Cash', + ticker: 'bch', + value: 0.001982942950598187, + currencyType: 'crypto', + }, + bnb: { + name: 'Binance Coin', + ticker: 'bnb', + value: 0.0015156056764231698, + currencyType: 'crypto', + }, + eos: { + name: 'EOS', + ticker: 'eos', + value: 2.056880058128908, + currencyType: 'crypto', + }, + xrp: { + name: 'XRP', + ticker: 'xrp', + value: 0.4540842119866674, + currencyType: 'crypto', + }, + xlm: { + name: 'Lumens', + ticker: 'xlm', + value: 4.29161071887215, + currencyType: 'crypto', + }, + link: { + name: 'Chainlink', + ticker: 'link', + value: 0.07546219704388624, + currencyType: 'crypto', + }, + dot: { + name: 'Polkadot', + ticker: 'dot', + value: 0.29389602831032285, + currencyType: 'crypto', + }, + yfi: { + name: 'Yearn.finance', + ticker: 'yfi', + value: 0.00019925282680837832, + currencyType: 'crypto', + }, + usd: { + name: 'US Dollar', + ticker: 'usd', + value: 1, + currencyType: 'fiat', + }, + aed: { + name: 'United Arab Emirates Dirham', + ticker: 'aed', + value: 3.6730349953852555, + currencyType: 'fiat', + }, + ars: { + name: 'Argentine Peso', + ticker: 'ars', + value: 1206.0000013561519, + currencyType: 'fiat', + }, + aud: { + name: 'Australian Dollar', + ticker: 'aud', + value: 1.5232439935923583, + currencyType: 'fiat', + }, + bdt: { + name: 'Bangladeshi Taka', + ticker: 'bdt', + value: 122.29205113607277, + currencyType: 'fiat', + }, + bhd: { + name: 'Bahraini Dinar', + ticker: 'bhd', + value: 0.3769909979846017, + currencyType: 'fiat', + }, + bmd: { + name: 'Bermudian Dollar', + ticker: 'bmd', + value: 1, + currencyType: 'fiat', + }, + brl: { + name: 'Brazil Real', + ticker: 'brl', + value: 5.446300002410629, + currencyType: 'fiat', + }, + cad: { + name: 'Canadian Dollar', + ticker: 'cad', + value: 1.3640219988479354, + currencyType: 'fiat', + }, + chf: { + name: 'Swiss Franc', + ticker: 'chf', + value: 0.7936309928980179, + currencyType: 'fiat', + }, + clp: { + name: 'Chilean Peso', + ticker: 'clp', + value: 923.830001036303, + currencyType: 'fiat', + }, + cny: { + name: 'Chinese Yuan', + ticker: 'cny', + value: 7.166700000015684, + currencyType: 'fiat', + }, + czk: { + name: 'Czech Koruna', + ticker: 'czk', + value: 20.952984017733154, + currencyType: 'fiat', + }, + dkk: { + name: 'Danish Krone', + ticker: 'dkk', + value: 6.339276002611524, + currencyType: 'fiat', + }, + eur: { + name: 'Euro', + ticker: 'eur', + value: 0.8496419976174352, + currencyType: 'fiat', + }, + gbp: { + name: 'British Pound Sterling', + ticker: 'gbp', + value: 0.7356629966217338, + currencyType: 'fiat', + }, + gel: { + name: 'Georgian Lari', + ticker: 'gel', + value: 2.719999997416854, + currencyType: 'fiat', + }, + hkd: { + name: 'Hong Kong Dollar', + ticker: 'hkd', + value: 7.84986500616371, + currencyType: 'fiat', + }, + huf: { + name: 'Hungarian Forint', + ticker: 'huf', + value: 340.2474533753413, + currencyType: 'fiat', + }, + idr: { + name: 'Indonesian Rupiah', + ticker: 'idr', + value: 16212.776418318166, + currencyType: 'fiat', + }, + ils: { + name: 'Israeli New Shekel', + ticker: 'ils', + value: 3.3717049952207647, + currencyType: 'fiat', + }, + inr: { + name: 'Indian Rupee', + ticker: 'inr', + value: 85.59833408842695, + currencyType: 'fiat', + }, + jpy: { + name: 'Japanese Yen', + ticker: 'jpy', + value: 143.9902001614485, + currencyType: 'fiat', + }, + krw: { + name: 'South Korean Won', + ticker: 'krw', + value: 1359.3506945328236, + currencyType: 'fiat', + }, + kwd: { + name: 'Kuwaiti Dinar', + ticker: 'kwd', + value: 0.30529199289535164, + currencyType: 'fiat', + }, + lkr: { + name: 'Sri Lankan Rupee', + ticker: 'lkr', + value: 299.9010793298127, + currencyType: 'fiat', + }, + mmk: { + name: 'Burmese Kyat', + ticker: 'mmk', + value: 2098.0000023617336, + currencyType: 'fiat', + }, + mxn: { + name: 'Mexican Peso', + ticker: 'mxn', + value: 18.77348001704397, + currencyType: 'fiat', + }, + myr: { + name: 'Malaysian Ringgit', + ticker: 'myr', + value: 4.228999997038608, + currencyType: 'fiat', + }, + ngn: { + name: 'Nigerian Naira', + ticker: 'ngn', + value: 1532.4200017290473, + currencyType: 'fiat', + }, + nok: { + name: 'Norwegian Krone', + ticker: 'nok', + value: 10.109898008254978, + currencyType: 'fiat', + }, + nzd: { + name: 'New Zealand Dollar', + ticker: 'nzd', + value: 1.6478669960903807, + currencyType: 'fiat', + }, + php: { + name: 'Philippine Peso', + ticker: 'php', + value: 56.376001062558736, + currencyType: 'fiat', + }, + pkr: { + name: 'Pakistani Rupee', + ticker: 'pkr', + value: 285.2245003132019, + currencyType: 'fiat', + }, + pln: { + name: 'Polish Zloty', + ticker: 'pln', + value: 3.625871995197858, + currencyType: 'fiat', + }, + rub: { + name: 'Russian Ruble', + ticker: 'rub', + value: 78.79997408366326, + currencyType: 'fiat', + }, + sar: { + name: 'Saudi Riyal', + ticker: 'sar', + value: 3.7501600005365567, + currencyType: 'fiat', + }, + sek: { + name: 'Swedish Krona', + ticker: 'sek', + value: 9.55167101005786, + currencyType: 'fiat', + }, + sgd: { + name: 'Singapore Dollar', + ticker: 'sgd', + value: 1.2739619998345126, + currencyType: 'fiat', + }, + thb: { + name: 'Thai Baht', + ticker: 'thb', + value: 32.40583303378832, + currencyType: 'fiat', + }, + try: { + name: 'Turkish Lira', + ticker: 'try', + value: 39.788298041452094, + currencyType: 'fiat', + }, + twd: { + name: 'New Taiwan Dollar', + ticker: 'twd', + value: 29.018999031034188, + currencyType: 'fiat', + }, + uah: { + name: 'Ukrainian hryvnia', + ticker: 'uah', + value: 41.75092204711494, + currencyType: 'fiat', + }, + vef: { + name: 'Venezuelan bolĂ­var fuerte', + ticker: 'vef', + value: 0.10012999775478468, + currencyType: 'fiat', + }, + vnd: { + name: 'Vietnamese đồng', + ticker: 'vnd', + value: 26167.73565956473, + currencyType: 'fiat', + }, + zar: { + name: 'South African Rand', + ticker: 'zar', + value: 17.638879012711193, + currencyType: 'fiat', + }, + xdr: { + name: 'IMF Special Drawing Rights', + ticker: 'xdr', + value: 0.6961849947454656, + currencyType: 'fiat', + }, + xag: { + name: 'Silver - Troy Ounce', + ticker: 'xag', + value: 0.02745114996087133, + currencyType: 'commodity', + }, + xau: { + name: 'Gold - Troy Ounce', + ticker: 'xau', + value: 0.0002992943887080938, + currencyType: 'commodity', + }, + bits: { + name: 'Bits', + ticker: 'bits', + value: 9.225522122806664, + currencyType: 'crypto', + }, + sats: { + name: 'Satoshi', + ticker: 'sats', + value: 922.5522122806664, + currencyType: 'crypto', + }, + cop: { + name: 'Colombian Peso', + ticker: 'cop', + value: 4020.329999998432, + currencyType: 'fiat', + }, + kes: { + name: 'Kenyan Shilling', + ticker: 'kes', + value: 129.20000000184513, + currencyType: 'fiat', + }, + ron: { + name: 'Romanian Leu', + ticker: 'ron', + value: 4.302400003896861, + currencyType: 'fiat', + }, + dop: { + name: 'Dominican Peso', + ticker: 'dop', + value: 59.421077000552856, + currencyType: 'fiat', + }, + crc: { + name: 'Costa Rican ColĂłn', + ticker: 'crc', + value: 505.1511230011281, + currencyType: 'fiat', + }, + hnl: { + name: 'Honduran Lempira', + ticker: 'hnl', + value: 26.133209998558144, + currencyType: 'fiat', + }, + zmw: { + name: 'Zambian Kwacha', + ticker: 'zmw', + value: 24.02423300185325, + currencyType: 'fiat', + }, + svc: { + name: 'Salvadoran ColĂłn', + ticker: 'svc', + value: 8.749590998008589, + currencyType: 'fiat', + }, + bam: { + name: 'Bosnia and Herzegovina Convertible Mark', + ticker: 'bam', + value: 1.6618870036093658, + currencyType: 'fiat', + }, + pen: { + name: 'Peruvian Sol', + ticker: 'pen', + value: 3.5611860013883123, + currencyType: 'fiat', + }, + gtq: { + name: 'Guatemalan Quetzal', + ticker: 'gtq', + value: 7.688288003161476, + currencyType: 'fiat', + }, + lbp: { + name: 'Lebanese Pound', + ticker: 'lbp', + value: 89577.29288500333, + currencyType: 'fiat', + }, + amd: { + name: 'Armenian Dram', + ticker: 'amd', + value: 384.5100000000923, + currencyType: 'fiat', + }, + sol: { + name: 'Solana', + ticker: 'sol', + value: 0.006629188747026665, + currencyType: 'crypto', + }, + sei: { + name: 'Sei Network', + ticker: 'sei', + value: 3.571422841670739, + currencyType: 'crypto', + }, + sonic: { + name: 'Sonic', + ticker: 'sonic', + value: 3.0932878113426843, + currencyType: 'crypto', + }, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/spot-prices.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/spot-prices.ts new file mode 100644 index 00000000..4d24f92c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/price-api/spot-prices.ts @@ -0,0 +1,90 @@ +/** + * HEADS UP! Changing this mock MUST involve changing the exchange rates mock too! + * Their values are interdependent and essential for the TokenPricesService tests. + */ +export const MOCK_SPOT_PRICES = { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + id: 'bitcoin', + price: 108383, + marketCap: 2153703484251, + allTimeHigh: 111814, + allTimeLow: 67.81, + totalVolume: 26490441505, + high1d: 108312, + low1d: 105402, + circulatingSupply: 19886487, + dilutedMarketCap: 2153703484251, + marketCapPercentChange1d: 2.32194, + priceChange1d: 2558.06, + pricePercentChange1h: 0.3843563748092404, + pricePercentChange1d: 2.417256898831376, + pricePercentChange7d: 0.5848420826167852, + pricePercentChange14d: 3.573582647113796, + pricePercentChange30d: 4.0364417287116305, + pricePercentChange200d: 6.841043820722927, + pricePercentChange1y: 74.83031943795173, + }, + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 2472.85, + marketCap: 298533579846, + allTimeHigh: 4878.26, + allTimeLow: 0.432979, + totalVolume: 9053920577, + high1d: 2473.06, + low1d: 2393.31, + circulatingSupply: 120717388.8264203, + dilutedMarketCap: 298533579846, + marketCapPercentChange1d: 2.15855, + priceChange1d: 52.3, + pricePercentChange1h: 0.7092921207897362, + pricePercentChange1d: 2.160678145136992, + pricePercentChange7d: 1.7425998170003503, + pricePercentChange14d: -1.2732287255912829, + pricePercentChange30d: -2.09981500745285, + pricePercentChange200d: -36.383873763555656, + pricePercentChange1y: -27.526564808866777, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + id: 'solana', + price: 150.62, + marketCap: 80423231441, + allTimeHigh: 293.31, + allTimeLow: 0.500801, + totalVolume: 3556506112, + high1d: 150.43, + low1d: 145.46, + circulatingSupply: 534608592.310483, + dilutedMarketCap: 90908091780, + marketCapPercentChange1d: 2.29743, + priceChange1d: 3.56, + pricePercentChange1h: 0.8121844458320602, + pricePercentChange1d: 2.421143543804292, + pricePercentChange7d: 3.176470205229667, + pricePercentChange14d: 3.6015257116898223, + pricePercentChange30d: -1.7218014883767463, + pricePercentChange200d: -32.14283758271846, + pricePercentChange1y: 1.6989732581584913, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'usd-coin', + price: 0.999884, + marketCap: 61656570570, + allTimeHigh: 1.17, + allTimeLow: 0.877647, + totalVolume: 7485377052, + high1d: 0.999925, + low1d: 0.999805, + circulatingSupply: 61662495506.43694, + dilutedMarketCap: 61685561207, + marketCapPercentChange1d: 0.11783, + priceChange1d: 0.00002247, + pricePercentChange1h: -0.0020655637951524234, + pricePercentChange1d: 0.002247764341345683, + pricePercentChange7d: -0.006438761910950978, + pricePercentChange14d: 0.007949688389332093, + pricePercentChange30d: 0.014871097408860527, + pricePercentChange200d: -0.024999725392617834, + pricePercentChange1y: -0.09035143373815327, + }, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-image-svg.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-image-svg.ts new file mode 100644 index 00000000..639b28f9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-image-svg.ts @@ -0,0 +1 @@ +export const SOL_IMAGE_SVG = ``; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-keyring-accounts.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-keyring-accounts.ts new file mode 100644 index 00000000..efbdd02c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/solana-keyring-accounts.ts @@ -0,0 +1,273 @@ +import { mnemonicPhraseToBytes } from '@metamask/key-tree'; +import { SolMethod, SolScope } from '@metamask/keyring-api'; + +import type { SolanaKeyringAccount } from '../../../entities'; + +export const MOCK_SEED_PHRASE = + 'sugar interest animal afford dog imitate relief lizard width strategy embark midnight'; +export const MOCK_SEED_PHRASE_BYTES = mnemonicPhraseToBytes(MOCK_SEED_PHRASE); +export const MOCK_SEED_PHRASE_ENTROPY_SOURCE = '01JR0PQ12DKG65S4ZX6EEF5PZ2'; + +export const MOCK_SEED_PHRASE_2 = + 'flight baby section trick minute clog jar swear oblige expect start unlock'; +export const MOCK_SEED_PHRASE_2_BYTES = + mnemonicPhraseToBytes(MOCK_SEED_PHRASE_2); +export const MOCK_SEED_PHRASE_2_ENTROPY_SOURCE = '01JR0PT6PNGBN7MRM3MPEVQPC0'; + +/** + * Solana account scopes. + */ +export const ALL_SCOPES = [SolScope.Mainnet, SolScope.Testnet, SolScope.Devnet]; + +/** + * Mock Solana Keyring Accounts as they would actually be generated by the Solana Keyring when using seed phrase: + * + * sugar interest animal afford dog imitate relief lizard width strategy embark midnight + */ + +export const MOCK_SOLANA_KEYRING_ACCOUNT_0: SolanaKeyringAccount = { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/0'/0'`, + index: 0, + type: 'solana:data-account', + id: '4b445722-6766-4f99-ade5-c2c9295f21d0', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/0'/0'`, + index: 0, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES = Uint8Array.from([ + 201, 78, 195, 68, 173, 217, 17, 236, 239, 65, 202, 20, 18, 250, 160, 56, 151, + 150, 31, 187, 150, 2, 50, 207, 153, 155, 163, 236, 59, 134, 83, 102, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_0_PUBLIC_KEY_BYTES = Uint8Array.from([ + 0, 153, 176, 2, 143, 157, 158, 175, 50, 134, 226, 145, 237, 10, 87, 130, 63, + 185, 200, 241, 76, 205, 92, 21, 136, 75, 157, 88, 79, 89, 248, 223, 116, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_1: SolanaKeyringAccount = { + address: 'FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/1'/0'`, + index: 1, + type: 'solana:data-account', + id: '123e4567-e89b-12d3-a456-426614174001', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/1'/0'`, + index: 1, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES = Uint8Array.from([ + 58, 174, 98, 154, 253, 237, 125, 206, 109, 159, 134, 239, 189, 179, 106, 243, + 179, 3, 131, 173, 86, 94, 23, 72, 63, 239, 110, 125, 30, 210, 84, 194, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_2: SolanaKeyringAccount = { + address: '27h6cm6S9ag5y4ASi1a1vbTSKEsQMjEdfvZ6atPjmbuD', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/2'/0'`, + index: 2, + type: 'solana:data-account', + id: '123e4567-e89b-12d3-a456-426614174002', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/2'/0'`, + index: 2, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_2_PRIVATE_KEY_BYTES = Uint8Array.from([ + 217, 83, 246, 6, 62, 72, 189, 87, 62, 43, 66, 168, 205, 49, 195, 7, 119, 128, + 101, 85, 226, 215, 67, 23, 223, 247, 240, 157, 111, 214, 41, 42, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_3: SolanaKeyringAccount = { + address: '3SYHDFbhoxuTCYCGRw7KSL8GbwUoByjtvyy7pUZeAhh8', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/3'/0'`, + index: 3, + type: 'solana:data-account', + id: '123e4567-e89b-12d3-a456-426614174003', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/3'/0'`, + index: 3, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_3_PRIVATE_KEY_BYTES = Uint8Array.from([ + 197, 249, 13, 144, 108, 95, 220, 46, 159, 169, 72, 221, 192, 106, 72, 127, 10, + 3, 95, 186, 173, 83, 212, 239, 180, 41, 7, 213, 92, 250, 122, 122, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_4: SolanaKeyringAccount = { + address: 'FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/4'/0'`, + index: 4, + type: 'solana:data-account', + id: '123e4567-e89b-12d3-a456-426614174004', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/4'/0'`, + index: 4, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_4_PRIVATE_KEY_BYTES = Uint8Array.from([ + 182, 222, 182, 183, 103, 56, 65, 161, 67, 188, 55, 244, 249, 208, 7, 60, 253, + 87, 234, 231, 209, 140, 109, 175, 247, 42, 249, 9, 224, 62, 60, 54, +]); + +export const MOCK_SOLANA_KEYRING_ACCOUNT_5: SolanaKeyringAccount = { + address: 'GN1DST49kafBmRDnKKwUbR6nJWoqzSJBPoHwcTXzgmkH', + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/5'/0'`, + index: 5, + type: 'solana:data-account', + id: '123e4567-e89b-12d3-a456-426614174005', + options: { + entropySource: MOCK_SEED_PHRASE_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/5'/0'`, + index: 5, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, +}; + +export const MOCK_SOLANA_KEYRING_ACCOUNT_5_PRIVATE_KEY_BYTES = Uint8Array.from([ + 130, 137, 152, 17, 6, 45, 56, 235, 140, 148, 107, 75, 184, 175, 54, 227, 184, + 27, 142, 254, 188, 4, 157, 96, 132, 101, 35, 165, 225, 205, 245, 102, +]); + +export const MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0: SolanaKeyringAccount = + { + entropySource: MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/0'/0'`, + index: 0, + id: '123e4567-e89b-12d3-a456-426614174006', + type: 'solana:data-account', + address: '8fi28SQKWhzCaH5c2RrHLbpgQJctNG6NFrfZyCj51rJX', + options: { + entropySource: MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/0'/0'`, + index: 0, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, + }; + +export const MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES = + Uint8Array.from([ + 35, 162, 118, 171, 62, 232, 249, 151, 226, 117, 248, 5, 74, 33, 76, 163, + 204, 43, 227, 179, 113, 233, 221, 254, 63, 233, 35, 17, 13, 116, 143, 32, + ]); + +export const MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1: SolanaKeyringAccount = + { + entropySource: MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/1'/0'`, + index: 1, + id: '123e4567-e89b-12d3-a456-426614174007', + type: 'solana:data-account', + address: '4yQr5hGkYfXykkv8Q7YxAGBxkB9bd9ntLL7hJaz1tGTv', + options: { + entropySource: MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + derivationPath: `m/44'/501'/1'/0'`, + index: 1, + }, + methods: [ + SolMethod.SignAndSendTransaction, + SolMethod.SignTransaction, + SolMethod.SignMessage, + SolMethod.SignIn, + ], + scopes: ALL_SCOPES, + }; + +export const MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES = + Uint8Array.from([ + 242, 217, 241, 193, 39, 45, 61, 75, 78, 249, 46, 188, 251, 118, 33, 4, 86, + 81, 250, 148, 246, 106, 109, 244, 62, 17, 240, 246, 147, 126, 37, 10, + ]); + +export const MOCK_SOLANA_KEYRING_ACCOUNTS = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1, +] as const; + +export const MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES = { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_KEYRING_ACCOUNT_2.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_2_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_KEYRING_ACCOUNT_3.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_3_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_KEYRING_ACCOUNT_4.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_4_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_KEYRING_ACCOUNT_5.id]: + MOCK_SOLANA_KEYRING_ACCOUNT_5_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0.id]: + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + [MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1.id]: + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES, +} as const; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts new file mode 100644 index 00000000..0248c41c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts @@ -0,0 +1,355 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import type { MockSolanaRpc } from './startMockSolanaRpc'; +import { startMockSolanaRpc } from './startMockSolanaRpc'; + +describe('startMockSolanaRpc', () => { + let mockSolanaRpc: MockSolanaRpc; + + beforeAll(() => { + mockSolanaRpc = startMockSolanaRpc(); + }); + + afterAll(() => { + mockSolanaRpc.shutdown(); + }); + + const makeRpcRequest = async (method: string, params: any[] = []) => + fetch('http://localhost:8899', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: '0', + method, + params, + }), + }); + + it('returns a defined mock', () => { + expect(mockSolanaRpc).toBeDefined(); + }); + + it('returns an error for unmocked method', async () => { + const response = await makeRpcRequest('wrongMethod'); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + jsonrpc: '2.0', + id: '0', + error: { + code: -32601, + message: 'No mock registered for this method', + }, + }); + }); + + it('returns a mocked response for registered method', async () => { + const { mockResolvedResult } = mockSolanaRpc; + + mockResolvedResult({ + method: 'getBalance', + result: 100000000, + }); + + const response = await makeRpcRequest('getBalance'); + const body = await response.json(); + expect(body).toEqual({ + jsonrpc: '2.0', + id: '0', + result: 100000000, + }); + }); + + it('handles multiple mock registrations', async () => { + const { mockResolvedResult } = mockSolanaRpc; + + const mockBalance = 100000000; + + const mockBlockHeight = 123456; + + mockResolvedResult({ + method: 'getBalance', + result: mockBalance, + }); + + mockResolvedResult({ + method: 'getBlockHeight', + result: mockBlockHeight, + }); + + const balanceResponse = await makeRpcRequest('getBalance'); + const blockHeightResponse = await makeRpcRequest('getBlockHeight'); + + const bodyBalanceResponse = await balanceResponse.json(); + const bodyBlockHeightResponse = await blockHeightResponse.json(); + + expect(bodyBalanceResponse).toEqual({ + jsonrpc: '2.0', + id: '0', + result: mockBalance, + }); + expect(bodyBlockHeightResponse).toEqual({ + jsonrpc: '2.0', + id: '0', + result: mockBlockHeight, + }); + }); + + it('handles mockResolvedResultOnce correctly', async () => { + const { mockResolvedResultOnce } = mockSolanaRpc; + + const mockResult = { result: 'test1' }; + + mockResolvedResultOnce({ + method: 'testMethod', + result: mockResult, + }); + + const response1 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + + const data1 = await response1.json(); + expect(data1).toEqual({ + jsonrpc: '2.0', + id: '0', + result: mockResult, + }); + + // Second call should fail as the mock was consumed + const response2 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + + expect(response2.status).toBe(400); + const data2 = await response2.json(); + expect(data2).toEqual({ + jsonrpc: '2.0', + id: '0', + error: { + code: -32601, + message: 'No mock registered for this method', + }, + }); + }); + + it('consumes mockResolvedResultOnce calls in LIFO order', async () => { + const { mockResolvedResultOnce } = mockSolanaRpc; + + const mockResult1 = { result: 'test1' }; + const mockResult2 = { result: 'test2' }; + + mockResolvedResultOnce({ + method: 'testMethod', + result: mockResult1, + }); + + mockResolvedResultOnce({ + method: 'testMethod', + result: mockResult2, + }); + + // NOTE: We check that the mocks are consumed in LIFO order + + // First call should return mockResponse2 + const response1 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data1 = await response1.json(); + expect(data1).toEqual({ + jsonrpc: '2.0', + id: '0', + result: mockResult2, + }); + + // Second call should return mockResponse1 + const response2 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data2 = await response2.json(); + expect(data2).toEqual({ + jsonrpc: '2.0', + id: '0', + result: mockResult1, + }); + }); + + it('does not consume mockResolvedResult calls', async () => { + const { mockResolvedResult, mockResolvedResultOnce } = mockSolanaRpc; + + const oneTimeResult = { result: 'oneTime' }; + const persistentResult = { result: 'persistent' }; + + mockResolvedResultOnce({ + method: 'testMethod', + result: oneTimeResult, + }); + + mockResolvedResult({ + method: 'testMethod', + result: persistentResult, + }); + + // Both calls should return the persistent response + const response1 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data1 = await response1.json(); + expect(data1).toEqual({ + jsonrpc: '2.0', + id: '0', + result: persistentResult, + }); + + const response2 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data2 = await response2.json(); + expect(data2).toEqual({ + jsonrpc: '2.0', + id: '0', + result: persistentResult, + }); + }); + + it('handles mockRejectedError correctly', async () => { + const { mockRejectedError } = mockSolanaRpc; + + const mockError = { + code: -32000, + message: 'Test error', + }; + + mockRejectedError({ + method: 'testMethod', + error: mockError, + }); + + // Multiple calls should return the same error + for (let i = 0; i < 2; i++) { + const response = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ + jsonrpc: '2.0', + id: '0', + error: mockError, + }); + } + }); + + it('handles mockRejectedErrorOnce correctly', async () => { + const { mockRejectedErrorOnce } = mockSolanaRpc; + + const mockError = { + code: -32000, + message: 'One-time error', + }; + + mockRejectedErrorOnce({ + method: 'testMethod-toBeMockedOnce', + error: mockError, + }); + + // First call should return the error + const response1 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod-toBeMockedOnce' }), + }); + + expect(response1.status).toBe(400); + const data1 = await response1.json(); + expect(data1).toEqual({ + jsonrpc: '2.0', + id: '0', + error: mockError, + }); + + // Second call should return "no mock registered" error + const response2 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod-toBeMockedOnce' }), + }); + + expect(response2.status).toBe(400); + const data2 = await response2.json(); + expect(data2).toEqual({ + jsonrpc: '2.0', + id: '0', + error: { + code: -32601, + message: 'No mock registered for this method', + }, + }); + }); + + it('does not consume mockRejectedError calls', async () => { + const { mockRejectedError, mockRejectedErrorOnce } = mockSolanaRpc; + const oneTimeError = { + code: -32000, + message: 'One-time error', + }; + const persistentError = { + code: -32000, + message: 'Persistent error', + }; + + mockRejectedErrorOnce({ + method: 'testMethod', + error: oneTimeError, + }); + + mockRejectedError({ + method: 'testMethod', + error: persistentError, + }); + + // Both calls should return the persistent error + const response1 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data1 = await response1.json(); + expect(data1).toEqual({ + jsonrpc: '2.0', + id: '0', + error: persistentError, + }); + + const response2 = await fetch('http://localhost:8899', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'testMethod' }), + }); + const data2 = await response2.json(); + expect(data2).toEqual({ + jsonrpc: '2.0', + id: '0', + error: persistentError, + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.ts new file mode 100644 index 00000000..2be6cea5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.ts @@ -0,0 +1,224 @@ +import type { Json } from '@metamask/utils'; +import express from 'express'; + +import logger from '../../utils/logger'; +import { Stack } from '../../utils/stack'; + +export type MockedResolvedResult = { + method: string; + result: Json; +}; + +export type MockedRejectedError = { + method: string; + error: { + code: number; + message: string; + }; +}; + +// Add new types for implementation functions +export type MockImplementationFn = (params: any) => Json | Promise; + +export type MockSolanaRpc = { + mockResolvedResult: (mock: MockedResolvedResult) => void; + mockResolvedResultOnce: (mock: MockedResolvedResult) => void; + mockRejectedError: (mock: MockedRejectedError) => void; + mockRejectedErrorOnce: (mock: MockedRejectedError) => void; + mockImplementation: ( + method: string, + implementation: MockImplementationFn, + ) => void; + mockImplementationOnce: ( + method: string, + implementation: MockImplementationFn, + ) => void; + shutdown: () => void; + server: express.Application | null; +}; + +// Singleton server instance +const FIXED_PORT = 8899; +let app: express.Application | null = null; +let server: any; + +/** + * We store mocks in a Map where the key is the method name and the value is a + * Stack of responses. + * + * The stack is a convenient data structure that allows us to mimick the + * "Jest style syntax" with `mockResolvedResponseOnce` and `mockResolvedResponse`. + * + * Every call to `mockResolvedResponseOnce` adds an item to the stack. + * Every call to `mockResolvedResponse` adds a NON DESTACKABLE item to the stack. + * + * Every time the mock responds, if the item is destackable, it is removed from + * the stack. If the item is not destackable, it is returned without removing it + * from the stack. + */ +const mocks = new Map< + string, + Stack< + | Omit + | Omit + | { implementation: MockImplementationFn } + > +>(); + +/** + * Singleton express app: creates it if it does not exist, otherwise returns + * the existing one. + */ +const createAppIfNotExists = () => { + if (!app) { + app = express(); + app.use(express.json()); + + app.post('/', async (req: any, res: any) => { + const { method, params, id: requestId } = req.body; + const id = requestId ?? '0'; + + const mockStack = mocks.get(method); + if (!mockStack) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: 'No mock registered for this method', + }, + }); + } + + const mock = mockStack.pop(); + if (!mock) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: 'No mock registered for this method', + }, + }); + } + + if ('error' in mock) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: mock.error, + }); + } + + if ('implementation' in mock) { + try { + console.log( + 'Calling implementation with params:', + JSON.stringify(params, null, 2), + ); + const result = await mock.implementation(params); + return res.json({ + jsonrpc: '2.0', + id, + result, + }); + } catch (error) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32603, + message: + error instanceof Error ? error.message : 'Implementation error', + }, + }); + } + } + + return res.json({ + jsonrpc: '2.0', + id, + result: mock.result, + }); + }); + + server = app.listen(FIXED_PORT); + logger.info(`Mock Solana RPC listening on port ${FIXED_PORT}`); + } +}; + +/** + * Starts a mock Solana RPC server on local port 8899, and returns utility + * methods to mock Solana RPC responses and errors. + * + * @returns An object with utility methods to mock Solana RPC responses and errors. + * @example + * ```ts + * const { mockResolvedResult, mockRejectedError, shutdown } = startMockSolanaRpc(); + * + * // Mock a resolved result once + * mockResolvedResultOnce({ method: 'getBalance', result: { balance: 1000 } }); + * mockResolvedResultOnce({ method: 'getBalance', result: { balance: 2000 } }); + * + * // Mock a rejected error + * mockRejectedError({ method: 'sendTransaction', error: { code: -32000, message: 'Insufficient funds' } }); + * ``` + */ +export const startMockSolanaRpc = (): MockSolanaRpc => { + createAppIfNotExists(); + + const mockResolvedResult = ({ method, result }: MockedResolvedResult) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ result }, false); // Non destackable + mocks.set(method, stack); + }; + + const mockResolvedResultOnce = ({ method, result }: MockedResolvedResult) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ result }, true); // Destackable + mocks.set(method, stack); + }; + + const mockRejectedError = ({ method, error }: MockedRejectedError) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ error }, false); // Non destackable + mocks.set(method, stack); + }; + + const mockRejectedErrorOnce = ({ method, error }: MockedRejectedError) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ error }, true); // Destackable + mocks.set(method, stack); + }; + + const mockImplementation = ( + method: string, + implementation: MockImplementationFn, + ) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ implementation }, false); // Non destackable + mocks.set(method, stack); + }; + + const mockImplementationOnce = ( + method: string, + implementation: MockImplementationFn, + ) => { + const stack = mocks.get(method) ?? new Stack(); + stack.push({ implementation }, true); // Destackable + mocks.set(method, stack); + }; + + const shutdown = () => server.close(); + + return { + mockResolvedResult, + mockResolvedResultOnce, + mockRejectedError, + mockRejectedErrorOnce, + mockImplementation, + mockImplementationOnce, + shutdown, + server: app, + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-1.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-1.ts new file mode 100644 index 00000000..5c38ee7f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-1.ts @@ -0,0 +1,175 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + StringifiedBigInt, + StringifiedNumber, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_1_TRANSACTION_1_DATA: SolanaTransaction = { + blockTime: 1737042268n as UnixTimestamp, + meta: { + computeUnitsConsumed: 9563n, + // eslint-disable-next-line id-denylist + err: null, + fee: 13318n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 4618 of 12131 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 7513 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + 189181197766n, + 0n, + 2039280n, + 2039280n, + 1n, + 1n, + 1009200n, + 934087680n, + 731913600n, + 2783815040n, + 1461600n, + ] as Lamports[], + postTokenBalances: [ + { + accountIndex: 2, + mint: asAddress('HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'), + owner: asAddress('H3sjyipQtXAJkvWNkXhDgped7k323kAba8QMwCLcV79w'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '599965960000456' as StringifiedBigInt, + decimals: 6, + uiAmount: 599965960.000456, + uiAmountString: '599965960.000456' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: asAddress('HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'), + owner: asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '20000000' as StringifiedBigInt, + decimals: 6, + uiAmount: 20.0, + uiAmountString: '20' as StringifiedNumber, + }, + }, + ], + preBalances: [ + 189181211084n, + 0n, + 2039280n, + 2039280n, + 1n, + 1n, + 1009200n, + 934087680n, + 731913600n, + 2783815040n, + 1461600n, + ] as Lamports[], + preTokenBalances: [ + { + accountIndex: 2, + mint: asAddress('HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'), + owner: asAddress('H3sjyipQtXAJkvWNkXhDgped7k323kAba8QMwCLcV79w'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '599965970000456' as StringifiedBigInt, + decimals: 6, + uiAmount: 599965970.000456, + uiAmountString: '599965970.000456' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: asAddress('HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'), + owner: asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '10000000' as StringifiedBigInt, + decimals: 6, + uiAmount: 10.0, + uiAmountString: '10' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { Ok: null }, + }, + slot: 354529792n, + transaction: { + message: { + accountKeys: [ + asAddress('BS5QyQcsFbrep8mNrCh5DtnTaihB2C2HUZEW8UJbY2xM'), + asAddress('H3sjyipQtXAJkvWNkXhDgped7k323kAba8QMwCLcV79w'), + asAddress('92N8pJdw1ZZUjo3FALH6QcnUB421DdF5urnL6idAgwFA'), + asAddress('BAnWT8bqq5KNTbq1uuCNk3xbTJP3ER7K9tRxtmsEDvSZ'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + asAddress('SysvarRent111111111111111111111111111111111'), + asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + asAddress('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 1, + numReadonlyUnsignedAccounts: 7, + numRequiredSignatures: 2, + }, + instructions: [ + { + accounts: [], + data: '3dtdQMzm1EQP' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + { + accounts: [], + data: 'HkM1hZ' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + { + accounts: [0, 3, 9, 10, 4, 7, 6], + data: '2' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: null, + }, + { + accounts: [2, 3, 1], + data: '3ay2hEw4e3yH' as Base58EncodedBytes, + programIdIndex: 7, + stackHeight: null, + }, + ], + recentBlockhash: + 'DtTefScQPkyAuurUHqwipH9g7G163p7jMX7NNSR44D66' as Blockhash, + }, + signatures: [ + 'signature-1', + '3pCGrAVxQ7h5oKV9pjzTZx4br3EpQChuJzWXi93CQMfapbSoqDt8hiJMRQRti1UzC6saoBdBjL2gBw1ekfJjqixG', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-2.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-2.ts new file mode 100644 index 00000000..1cd83493 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-2.ts @@ -0,0 +1,204 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + StringifiedBigInt, + StringifiedNumber, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_1_TRANSACTION_2_DATA: SolanaTransaction = { + blockTime: 1737042188n as UnixTimestamp, + meta: { + computeUnitsConsumed: 28695n, + // eslint-disable-next-line id-denylist + err: null, + fee: 15000n as Lamports, + innerInstructions: [ + { + index: 2, + instructions: [ + { + accounts: [7], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 9, + stackHeight: 2, + }, + { + accounts: [0, 2], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: 2, + }, + { + accounts: [2], + data: 'P' as Base58EncodedBytes, + programIdIndex: 9, + stackHeight: 2, + }, + { + accounts: [2, 7], + data: '6XUdiN9B74WsHrJPwmwamQbaUXRoYfHSVCBnsJfParEKM' as Base58EncodedBytes, + programIdIndex: 9, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: Create', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1595 of 28101 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 21488 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4214 of 17604 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 22141 of 35193 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6254 of 13052 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + 5297910720n, + 2039280n, + 2039280n, + 1n, + 731913600n, + 2783815040n, + 1n, + 68479484100n, + 1009200n, + 934087680n, + ] as Lamports[], + postTokenBalances: [ + { + accountIndex: 1, + mint: asAddress('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'), + owner: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '500000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.5, + uiAmountString: '0.5' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: asAddress('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'), + owner: asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '500000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.5, + uiAmountString: '0.5' as StringifiedNumber, + }, + }, + ], + preBalances: [ + 5299965000n, + 2039280n, + 0n, + 1n, + 731913600n, + 2783815040n, + 1n, + 68479484100n, + 1009200n, + 934087680n, + ] as Lamports[], + preTokenBalances: [ + { + accountIndex: 1, + mint: asAddress('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'), + owner: asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1000000' as StringifiedBigInt, + decimals: 6, + uiAmount: 1.0, + uiAmountString: '1' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { Ok: null }, + }, + slot: 354529585n, + transaction: { + message: { + accountKeys: [ + asAddress('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + asAddress('4dj1oSSzmpJ2a4biKE6W8ME2bRzwGcjddcVXBuvE4Y4x'), + asAddress('4XRbx7Ut74GPQGNmceU2hcxQUspJoi8rU1Ej4Tj6GX1w'), + asAddress('11111111111111111111111111111111'), + asAddress('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('ComputeBudget111111111111111111111111111111'), + asAddress('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'), + asAddress('SysvarRent111111111111111111111111111111111'), + asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 7, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3cvcr9dfxjhH' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [], + data: 'JKUyEw' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [0, 2, 5, 7, 3, 9, 8], + data: '' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [1, 7, 2, 0, 0], + data: 'gX37MVsfGUBn5' as Base58EncodedBytes, + programIdIndex: 9, + stackHeight: null, + }, + ], + recentBlockhash: + 'AzfhwvmnNT4veqCReET7d5edqSpEt7oH9JCYh4yqoB11' as Blockhash, + }, + signatures: ['signature-2'] as Base58EncodedBytes[], + }, + version: 'legacy', +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-3.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-3.ts new file mode 100644 index 00000000..12fcc9a5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-3.ts @@ -0,0 +1,115 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + StringifiedBigInt, + StringifiedNumber, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_1_TRANSACTION_3_DATA: SolanaTransaction = { + blockTime: 1736940723n as UnixTimestamp, + meta: { + computeUnitsConsumed: 4644n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 200000 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [2783815040n, 2039280n, 2039280n, 934087680n] as Lamports[], + postTokenBalances: [ + { + accountIndex: 1, + mint: asAddress('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: asAddress('BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '130000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.13, + uiAmountString: '0.13' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: asAddress('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7609876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.609876, + uiAmountString: '7.609876' as StringifiedNumber, + }, + }, + ], + preBalances: [2783820040n, 2039280n, 2039280n, 934087680n] as Lamports[], + preTokenBalances: [ + { + accountIndex: 1, + mint: asAddress('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: asAddress('BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '120000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.12, + uiAmountString: '0.12' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: asAddress('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7619876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.619876, + uiAmountString: '7.619876' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { Ok: null }, + }, + slot: 354263676n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('644PJ6UW8e4gQpjKdBVd4MCYasWjSECKjqd2qzdeAJY6'), + asAddress('G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA'), + asAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [2, 1, 0], + data: '3GAG5eogvTjV' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + ], + recentBlockhash: + 'AHkrNj8Mk9xH64SzQwHYkg1HRmMZL7ZABgvNGDy56A5p' as Blockhash, + }, + signatures: ['signature-3'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-4.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-4.ts new file mode 100644 index 00000000..e76afac6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-4.ts @@ -0,0 +1,69 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_1_TRANSACTION_4_DATA: SolanaTransaction = { + blockTime: 1736791224n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [2783820040n, 7500935000n, 1n, 1n] as Lamports[], + postTokenBalances: [], + preBalances: [2883825040n, 7400935000n, 1n, 1n] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353870768n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: + '6ozFDhGMSjqJuDc3HsjWNwejBviFDaF9XWgkLyjQzFx8' as Blockhash, + }, + signatures: ['signature-4'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-1.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-1.ts new file mode 100644 index 00000000..717ed8b7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-1.ts @@ -0,0 +1,69 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_2_TRANSACTION_1_DATA: SolanaTransaction = { + blockTime: 1736791224n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [2783820040n, 7500935000n, 1n, 1n] as Lamports[], + postTokenBalances: [], + preBalances: [2883825040n, 7400935000n, 1n, 1n] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353870768n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: + '6ozFDhGMSjqJuDc3HsjWNwejBviFDaF9XWgkLyjQzFx8' as Blockhash, + }, + signatures: ['signature-5'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-2.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-2.ts new file mode 100644 index 00000000..52a388d3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-2.ts @@ -0,0 +1,69 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_2_TRANSACTION_2_DATA: SolanaTransaction = { + blockTime: 1736790937n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [2883830040n, 7400935000n, 1n, 1n] as Lamports[], + postTokenBalances: [], + preBalances: [2983835040n, 7300935000n, 1n, 1n] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353870020n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: + 'H3UXVntFNsvkEPxoMpZhN23SXvn8TstXCmexPrt4YcJt' as Blockhash, + }, + signatures: ['signature-6'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-3.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-3.ts new file mode 100644 index 00000000..84237f97 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-3.ts @@ -0,0 +1,69 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_2_TRANSACTION_3_DATA: SolanaTransaction = { + blockTime: 1736790801n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [2983835040n, 7300935000n, 1n, 1n] as Lamports[], + postTokenBalances: [], + preBalances: [3083840040n, 7200935000n, 1n, 1n] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353869664n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: + '7pD7SyNQAazBGrP7FBHUbcbgXndk31KN7ZstoJDEa7HP' as Blockhash, + }, + signatures: ['signature-7'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-4.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-4.ts new file mode 100644 index 00000000..985b5a38 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-4.ts @@ -0,0 +1,69 @@ +import type { + Base58EncodedBytes, + Blockhash, + Lamports, + Slot, + UnixTimestamp, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../../types/solana'; + +export const ADDRESS_2_TRANSACTION_4_DATA: SolanaTransaction = { + blockTime: 1736778620n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: 5000n as Lamports, + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [3083845040n, 7200935000n, 1n, 1n] as Lamports[], + postTokenBalances: [], + preBalances: [3183850040n, 7100935000n, 1n, 1n] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353838024n, + transaction: { + message: { + accountKeys: [ + asAddress('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + asAddress('FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo'), + asAddress('11111111111111111111111111111111'), + asAddress('ComputeBudget111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: + '7F3LS15wnw4oXRKWAxjCsqPRqoRyKCMo1TZGVkBn6a4q' as Blockhash, + }, + signatures: ['signature-8'] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/failed-swap.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/failed-swap.ts new file mode 100644 index 00000000..93d68d44 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/failed-swap.ts @@ -0,0 +1,539 @@ +import { address, blockhash, lamports } from '@solana/kit'; +import type { StringifiedBigInt, StringifiedNumber } from '@solana/kit'; +import type { Base58EncodedBytes, Lamports, UnixTimestamp } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +export const EXPECTED_FAILED_SWAP_DATA: SolanaTransaction = { + blockTime: 1747040326n as UnixTimestamp, + meta: { + computeUnitsConsumed: 30602n, + // eslint-disable-next-line id-denylist + err: { + InstructionError: [ + 6, + { + Custom: 1, + }, + ], + }, + fee: 520451n as Lamports, + innerInstructions: [ + { + index: 5, + instructions: [ + { + accounts: [37], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 2, + }, + { + accounts: [0, 7], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 10, + stackHeight: 2, + }, + { + accounts: [7], + data: 'P' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 2, + }, + { + accounts: [7, 37], + data: '6UHkqm5jFzWsk2Gy7yoV1kHy1VYte5VBBs4cBx1xPsUhA' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME'), + address('8BSWYgAczR36C7ukr32v7uTepoRhYJYxAVnpBtYniZTm'), + address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + address('stab1io8dHvK26KoHmTwwHyYmHRbUWbyEJx6CdrGabC'), + address('swapNyd8XiQwJ6ianp9snpu4brUqFxadzvHebnAXjJZ'), + address('vo1tWgqZMjG61Z2T9qUaMYKqZ75CYzMuaZ2LZP1n7HV'), + address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + address('So11111111111111111111111111111111111111112'), + address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + address('whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc'), + ], + writable: [ + address('9eNZsg6Nodfq8mCcMkxE8WcZ8TPR86NfaG2cHupaQyHr'), + address('AioJRQXvcDLRhHMd6DAkTbbMpgVx63qSGQYmRBS2vHYA'), + address('ArLSJrSstZ3kjeZDyMAgjfjad1qdRZHHYaCQTQeAcTpa'), + address('EszmvzMNgMPDysbAJBvqPC4DtUcU76KvBVfC11r8sSv7'), + address('7EYpUUcMQU5kX14nmXmgsNinhYaCX4yNu4M5UhyTJrRn'), + address('8YW7kG5nZGL36mEHgJSdzvrhHXPV9x9R85NHHFtcoJK9'), + address('9Vh6fqJjDkqSTZ8bDXseVxGb2yQEMkEhhtte2anQCHSf'), + address('BoupoyPJU1EgwRUhKw78dQKjPrv26SYgtmtXas9axdT8'), + address('EQcD4Wcp9fk1vnPT1q8r3KrPd3euTn3zuwjRtuEvDjTc'), + ], + }, + logMessages: [ + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br invoke [1]', + 'Program log: LI.FI TX: 0xBC1F5C8717748E00', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br consumed 4537 of 1400000 compute units', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 1383458 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 1376871 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 1372989 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 25315 of 1394863 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Transfer: insufficient lamports 3919590, need 5947500', + 'Program 11111111111111111111111111111111 failed: custom program error: 0x1', + ], + postBalances: [ + lamports(6011370n), + lamports(70407360n), + lamports(34716468251n), + lamports(70407360n), + lamports(130629057n), + lamports(2039580n), + lamports(1186884779n), + lamports(0n), + lamports(2192400n), + lamports(2039280n), + lamports(1n), + lamports(1141440n), + lamports(731913600n), + lamports(13627512455n), + lamports(1n), + lamports(1017918n), + lamports(0n), + lamports(1141440n), + lamports(1169280n), + lamports(934087680n), + lamports(2895360n), + lamports(2039280n), + lamports(2039280n), + lamports(2192400n), + lamports(70407360n), + lamports(0n), + lamports(6437076n), + lamports(2164560n), + lamports(26968959616n), + lamports(931552n), + lamports(0n), + lamports(390010697122n), + lamports(1920960n), + lamports(1141440n), + lamports(1141440n), + lamports(2061518951n), + lamports(521498880n), + lamports(1041216875087n), + lamports(1141440n), + lamports(1141440n), + ], + postTokenBalances: [ + { + accountIndex: 5, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '128934129' as StringifiedBigInt, + decimals: 6, + uiAmount: 128.934129, + uiAmountString: '128.934129' as StringifiedNumber, + }, + }, + { + accountIndex: 6, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1182844998' as StringifiedBigInt, + decimals: 9, + uiAmount: 1.182844998, + uiAmountString: '1.182844998' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '7396196' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.396196, + uiAmountString: '7.396196' as StringifiedNumber, + }, + }, + { + accountIndex: 9, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '565276' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.565276, + uiAmountString: '0.565276' as StringifiedNumber, + }, + }, + { + accountIndex: 21, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '2266530230612' as StringifiedBigInt, + decimals: 6, + uiAmount: 2266530.230612, + uiAmountString: '2266530.230612' as StringifiedNumber, + }, + }, + { + accountIndex: 22, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('8UgoPZAR8ZLoEmV6pJ8SZ6JKESP2X8nbnrZSdSgNtg1y'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '43641392767' as StringifiedBigInt, + decimals: 6, + uiAmount: 43641.392767, + uiAmountString: '43641.392767' as StringifiedNumber, + }, + }, + { + accountIndex: 23, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '303555984319' as StringifiedBigInt, + decimals: 6, + uiAmount: 303555.984319, + uiAmountString: '303555.984319' as StringifiedNumber, + }, + }, + { + accountIndex: 27, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('9Vh6fqJjDkqSTZ8bDXseVxGb2yQEMkEhhtte2anQCHSf'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '6401189699' as StringifiedBigInt, + decimals: 6, + uiAmount: 6401.189699, + uiAmountString: '6401.189699' as StringifiedNumber, + }, + }, + { + accountIndex: 28, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('9Vh6fqJjDkqSTZ8bDXseVxGb2yQEMkEhhtte2anQCHSf'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '26966920336' as StringifiedBigInt, + decimals: 9, + uiAmount: 26.966920336, + uiAmountString: '26.966920336' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(6531821n), + lamports(70407360n), + lamports(34716468251n), + lamports(70407360n), + lamports(130629057n), + lamports(2039580n), + lamports(1186884779n), + lamports(0n), + lamports(2192400n), + lamports(2039280n), + lamports(1n), + lamports(1141440n), + lamports(731913600n), + lamports(13627512455n), + lamports(1n), + lamports(1017918n), + lamports(0n), + lamports(1141440n), + lamports(1169280n), + lamports(934087680n), + lamports(2895360n), + lamports(2039280n), + lamports(2039280n), + lamports(2192400n), + lamports(70407360n), + lamports(0n), + lamports(6437076n), + lamports(2164560n), + lamports(26968959616n), + lamports(931552n), + lamports(0n), + lamports(390010697122n), + lamports(1920960n), + lamports(1141440n), + lamports(1141440n), + lamports(2061518951n), + lamports(521498880n), + lamports(1041216875087n), + lamports(1141440n), + lamports(1141440n), + ], + preTokenBalances: [ + { + accountIndex: 5, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '128934129' as StringifiedBigInt, + decimals: 6, + uiAmount: 128.934129, + uiAmountString: '128.934129' as StringifiedNumber, + }, + }, + { + accountIndex: 6, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1182844998' as StringifiedBigInt, + decimals: 9, + uiAmount: 1.182844998, + uiAmountString: '1.182844998' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '7396196' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.396196, + uiAmountString: '7.396196' as StringifiedNumber, + }, + }, + { + accountIndex: 9, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '565276' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.565276, + uiAmountString: '0.565276' as StringifiedNumber, + }, + }, + { + accountIndex: 21, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '2266530230612' as StringifiedBigInt, + decimals: 6, + uiAmount: 2266530.230612, + uiAmountString: '2266530.230612' as StringifiedNumber, + }, + }, + { + accountIndex: 22, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('8UgoPZAR8ZLoEmV6pJ8SZ6JKESP2X8nbnrZSdSgNtg1y'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '43641392767' as StringifiedBigInt, + decimals: 6, + uiAmount: 43641.392767, + uiAmountString: '43641.392767' as StringifiedNumber, + }, + }, + { + accountIndex: 23, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('7imnGYfCovXjMWKdbQvETFVMe72MQDX4S5zW4GFxMJME'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '303555984319' as StringifiedBigInt, + decimals: 6, + uiAmount: 303555.984319, + uiAmountString: '303555.984319' as StringifiedNumber, + }, + }, + { + accountIndex: 27, + mint: address('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo'), + owner: address('9Vh6fqJjDkqSTZ8bDXseVxGb2yQEMkEhhtte2anQCHSf'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '6401189699' as StringifiedBigInt, + decimals: 6, + uiAmount: 6401.189699, + uiAmountString: '6401.189699' as StringifiedNumber, + }, + }, + { + accountIndex: 28, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('9Vh6fqJjDkqSTZ8bDXseVxGb2yQEMkEhhtte2anQCHSf'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '26966920336' as StringifiedBigInt, + decimals: 9, + uiAmount: 26.966920336, + uiAmountString: '26.966920336' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Err: { + InstructionError: [ + 6, + { + Custom: 1, + }, + ], + }, + }, + }, + slot: 339492409n, + transaction: { + message: { + accountKeys: [ + address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + address('2giRfzAZuJWikRirBd2o4nwxcrbcJvLq3YKQ2qFnG8Dw'), + address('34FKjAdVcTax2DHqV2XnbXa9J3zmyKcFuFKWbcmgxjgm'), + address('3fqYBVJCTT5chsnCYNm8D8yTZ7BhckR7vLq8TQyLPHjv'), + address('4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA'), + address('7u7cD7NxcZEuzRCBaYo8uVpotRdqZwez47vvuwzCov43'), + address('8ctcHN52LY21FEipCjr1MVWtoZa1irJQTPyAaTj72h7S'), + address('9QQjgi73MnaeeMvUYZr9LTaZj4dt3ktstUF8s25zfiJ1'), + address('9XhdUGacSs3m4QUAmhp7ko5doSjFwVy1f9EzMygQxhCX'), + address('AxjEBpbCGoDuNP5CP7B8y1cWs76vEM3bwhJdvUGVn8Aw'), + address('11111111111111111111111111111111'), + address('3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV'), + address('ComputeBudget111111111111111111111111111111'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + address('DGUoqbQbFJz3NRCj4w6jtcGgHmmKLwk5JAAbqev76UPb'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('SysvarC1ock11111111111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [ + { + accountKey: address('DUb7B9NiL1NUfP8M9pAegZUgaHEjiDzq2M3WoxUJ4Zh6'), + readableIndexes: [149, 141, 140, 148, 139, 143], + writableIndexes: [147, 144, 142, 145], + }, + { + accountKey: address('E5YPrwXTW7iFPCPtamx5pcU9jdxHKnqZFmsf3do9sXsc'), + readableIndexes: [50, 38, 45, 46, 44], + writableIndexes: [43, 39, 40, 47, 48], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 10, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [18, 16], + data: '1YU2Pb9nzu35' as Base58EncodedBytes, + programIdIndex: 11, + }, + { + accounts: [], + data: 'JobjwD' as Base58EncodedBytes, + programIdIndex: 14, + }, + { + accounts: [], + data: '3N6KDp3JtFzP' as Base58EncodedBytes, + programIdIndex: 14, + }, + { + accounts: [0, 2], + data: '3Bxs3zrfFUZbEPqZ' as Base58EncodedBytes, + programIdIndex: 10, + }, + { + accounts: [0, 4], + data: '3Bxs44LTHG8KTRdh' as Base58EncodedBytes, + programIdIndex: 10, + }, + { + accounts: [0, 7, 0, 37, 10, 19], + data: '2' as Base58EncodedBytes, + programIdIndex: 12, + }, + { + accounts: [0, 7], + data: '3Bxs4K3gg4Fn6HhR' as Base58EncodedBytes, + programIdIndex: 10, + }, + { + accounts: [7], + data: 'J' as Base58EncodedBytes, + programIdIndex: 19, + }, + { + accounts: [ + 19, 13, 0, 7, 6, 5, 9, 37, 31, 17, 17, 15, 17, 39, 19, 38, 36, 13, + 26, 37, 35, 6, 28, 8, 27, 3, 1, 24, 25, 33, 13, 35, 31, 8, 5, 23, + 21, 22, 20, 30, 32, 29, 34, 19, 38, + ], + data: '4DwqHy1NgGjQKMvTx3xxoArLEm4rWW8BE3YRN2cngrtQXTPXgDDZS3PF99' as Base58EncodedBytes, + programIdIndex: 17, + }, + { + accounts: [7, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 19, + }, + ], + recentBlockhash: blockhash( + 'BL2MUCdTeD1ChQnPXoQRDrY1EijvUfvKtD4FvbMwgJEK', + ), + }, + signatures: [ + '43VK3TtYjN21VG13f2EPvNJxmML38GB8QbTDVtFifzeDW3LQpmLtFdLjERAKwy3k4RUe4Hizmdrj4Nyjm5vYKDBx' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer-to-self.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer-to-self.ts new file mode 100644 index 00000000..d3dc567a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer-to-self.ts @@ -0,0 +1,85 @@ +import { blockhash, lamports } from '@solana/kit'; +import type { + Address, + Base58EncodedBytes, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Send native SOL to self + * Transaction: 4Ccb8PaSob6JjsyDnoFJfUpJZDJHTwcjnK7MxiyVeMtPSsBGKuaMHEVL1VsXTKWS4w26tAhbc3T78aNELjfN8Zwb + * + * Senders: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP sends SOL to self + * + * Receivers: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP receives SOL (self-transfer) + */ +export const EXPECTED_NATIVE_SOL_TRANSFER_TO_SELF_DATA: SolanaTransaction = { + blockTime: 1741791493n as UnixTimestamp, + meta: { + computeUnitsConsumed: 300n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [lamports(2853645269n), lamports(1n), lamports(1n)], + postTokenBalances: [], + preBalances: [lamports(2853650269n), lamports(1n), lamports(1n)], + preTokenBalances: [], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 366849749n, + transaction: { + message: { + accountKeys: [ + 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + '11111111111111111111111111111111', + 'ComputeBudget111111111111111111111111111111', + ] as Address[], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FDJTAf' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + { + accounts: [0, 0], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 1, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '3JrVnd9x8o81Jd21tu2tuCfuhmce2C8uSfP9R3nPQVuc', + ), + }, + signatures: [ + '4Ccb8PaSob6JjsyDnoFJfUpJZDJHTwcjnK7MxiyVeMtPSsBGKuaMHEVL1VsXTKWS4w26tAhbc3T78aNELjfN8Zwb', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer.ts new file mode 100644 index 00000000..dd587d4c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer.ts @@ -0,0 +1,67 @@ +import type { Base58EncodedBytes, Slot, UnixTimestamp } from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Native SOL Transfer + * Transaction: 2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6 + * + * Senders: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP sends 0.1 SOL - OK + * + * Receivers: + * FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK gets 0.1 SOL - OK + */ +export const EXPECTED_NATIVE_SOL_TRANSFER_DATA: SolanaTransaction = { + blockTime: 1736500242n as UnixTimestamp, + meta: { + computeUnitsConsumed: 150n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { readonly: [], writable: [] }, + logMessages: [ + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [lamports(3283865040n), lamports(100000000n), lamports(1n)], + postTokenBalances: [], + preBalances: [lamports(3383870040n), lamports(0n), lamports(1n)], + preTokenBalances: [], + rewards: [], + status: { Ok: null }, + }, + slot: 353101424n, + transaction: { + message: { + accountKeys: [ + address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + address('FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK'), + address('11111111111111111111111111111111'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [0, 1], + data: '3Bxs411Dtc7pkFQj' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'Dd2KFRayr2fPNAKnKUuB6cSqEi4YSRhUPgG2BfaDTiLL', + ), + }, + signatures: [ + '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-jup-transfer-checked-to-self.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-jup-transfer-checked-to-self.ts new file mode 100644 index 00000000..7a0f741f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-jup-transfer-checked-to-self.ts @@ -0,0 +1,136 @@ +import type { + Address, + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Send JUP (TransferChecked) + * Transaction: 4zvFGpqjihSXgHdw6ymHA8hVfyHURNPwASz4FS4c9wADCMSooojx8k42EUuhoDiGGM73SixUcNXafgnuM5dnKHfH + * + * Senders: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa sends JUP tokens + * + * Receivers: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa receives JUP tokens + */ +export const EXPECTED_SEND_JUP_TRANSFER_CHECKED_DATA: SolanaTransaction = { + blockTime: 1742387710n as UnixTimestamp, + meta: { + computeUnitsConsumed: 6258n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(80001n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 5958 of 7335 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(45189040n), + lamports(2039280n), + lamports(1n), + lamports(69942826115n), + lamports(934087680n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4881197' as StringifiedBigInt, + decimals: 6, + uiAmount: 4.881197, + uiAmountString: '4.881197' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(45269041n), + lamports(2039280n), + lamports(1n), + lamports(69942826115n), + lamports(934087680n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4881197' as StringifiedBigInt, + decimals: 6, + uiAmount: 4.881197, + uiAmountString: '4.881197' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 327785925n, + transaction: { + message: { + accountKeys: [ + 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa', + 'BWqMUT5533X8M4DM3AQvFeAQd451urcVQu3zi6uVxWtT', + 'ComputeBudget111111111111111111111111111111', + 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + ] as Address[], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 3, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3pEM3r5AzBLK' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + { + accounts: [], + data: 'KV3niK' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + { + accounts: [1, 3, 1, 0, 0], + data: 'gvPShZQhKrzGM' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '3ocq1uiEiPfqD95hLCaeiyRri5t4bDTsE41FK5PF1fDN', + ), + }, + signatures: [ + '4zvFGpqjihSXgHdw6ymHA8hVfyHURNPwASz4FS4c9wADCMSooojx8k42EUuhoDiGGM73SixUcNXafgnuM5dnKHfH', + ] as Base58EncodedBytes[], + }, + version: 'legacy', +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-plonk-to-self.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-plonk-to-self.ts new file mode 100644 index 00000000..2f6d40e8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-plonk-to-self.ts @@ -0,0 +1,146 @@ +import type { Base58EncodedBytes } from '@solana/kit'; +import { + address, + blockhash, + lamports, + stringifiedBigInt, + stringifiedNumber, + unixTimestamp, +} from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Actual mainnet transaction where user sends 36 PLONK tokens to self. + */ +export const SEND_PLONK_TO_SELF: SolanaTransaction = { + blockTime: unixTimestamp(1756366325n), + meta: { + computeUnitsConsumed: 16514n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5400n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 10338 of 39700 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 5876 of 29362 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(55684784n), + lamports(2039280n), + lamports(1n), + lamports(747821958n), + lamports(1n), + lamports(7782328581n), + lamports(4575972223n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('HeqCcMjmuV5s25J49YiJyT6bD5qWLkP88YPajBySniaV'), + owner: address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: stringifiedBigInt('18206064911813'), + decimals: 5, + uiAmount: 182060649.11813, + uiAmountString: stringifiedNumber('182060649.11813'), + }, + }, + ], + preBalances: [ + lamports(55690184n), + lamports(2039280n), + lamports(1n), + lamports(747821958n), + lamports(1n), + lamports(7782328581n), + lamports(4575972223n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('HeqCcMjmuV5s25J49YiJyT6bD5qWLkP88YPajBySniaV'), + owner: address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: stringifiedBigInt('18206064911813'), + decimals: 5, + uiAmount: 182060649.11813, + uiAmountString: stringifiedNumber('182060649.11813'), + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 363027026n, + transaction: { + message: { + accountKeys: [ + address('8A4AptCThfbuknsbteHgGKXczfJpfjuVA9SLTSGaaLGC'), + address('9gsXphsNFyuTZ4P1gfxQEJJb8LjZUmNFJuWB2EWK9pB7'), + address('11111111111111111111111111111111'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('ComputeBudget111111111111111111111111111111'), + address('HeqCcMjmuV5s25J49YiJyT6bD5qWLkP88YPajBySniaV'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 5, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'FjrGSs' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [], + data: '3GAG5eogvTjV' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [0, 1, 0, 5, 2, 6], + data: '2' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + { + accounts: [1, 5, 1, 0], + data: 'hk8g7jZzuCnck' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'CKTr1insPppD3sDtDwDibGJhY3uMNwMiLTFABdqjAA6D', + ), + }, + signatures: [ + '4uDwB51U8Bp4j76ezUrgiP9rYR64QwmqsgMu8d63nXRmTddjYAKQ5Zs9aN1VoNGuNyzyFbMDJ3Gp4DirJds4yXzq' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-spl-token-and-create-token-account.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-spl-token-and-create-token-account.ts new file mode 100644 index 00000000..278cb9c2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-spl-token-and-create-token-account.ts @@ -0,0 +1,218 @@ +import type { + Address, + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Send SOL and Create Token Account (HeLp) + * Transaction: 4G24SgaZ3gU92HAB8xwSVg6WXS7NcGtUpHMnQ5RTwBw9bG5x8y6co5TzqqPXbExovY2NAuPjE9393TCHFZVhS8K9 + * + * Senders: + * EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE sends SOL and creates a token account for A16Z token + * + * Receivers: + * BYh4CfuGDvFMKaZp3RPmkw9y6qg3sWukA2TiGJDeLKZi receives A16Z tokens + */ +export const EXPECTED_SEND_SPL_TOKEN_AND_CREATE_TOKEN_ACCOUNT_DATA: SolanaTransaction = + { + blockTime: 1745927033n as UnixTimestamp, + meta: { + computeUnitsConsumed: 22184n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5222n), + innerInstructions: [ + { + index: 1, + instructions: [ + { + accounts: [7], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + { + accounts: [0, 1], + data: '11119ExAoTptm6xKUTUcw2V69MKmyEdDmRins3j3bK43o9nHeiYUtSiaT9pc292PhNQvxj' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: 2, + }, + { + accounts: [1], + data: 'P' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + { + accounts: [1, 7], + data: '6XgPj8sS4WmXBb4fL8ab7EubMbjFsHGBSrwdZksuTdTzg' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1436 of 13638 compute units', + 'Program return: TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb qgAAAAAAAAA=', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 529 of 7272 compute units', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 2167 of 4353 compute units', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20152 of 22034 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 1732 of 1882 compute units', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + ], + postBalances: [ + lamports(9877744n), + lamports(2074080n), + lamports(2074080n), + lamports(1n), + lamports(731913600n), + lamports(1000000n), + lamports(1n), + lamports(18359024537n), + lamports(1141440n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), + owner: address('BYh4CfuGDvFMKaZp3RPmkw9y6qg3sWukA2TiGJDeLKZi'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '10000000' as StringifiedBigInt, + decimals: 9, + uiAmount: 0.01, + uiAmountString: '0.01' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), + owner: address('EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 9, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(11957046n), + lamports(0n), + lamports(2074080n), + lamports(1n), + lamports(731913600n), + lamports(1000000n), + lamports(1n), + lamports(18359024537n), + lamports(1141440n), + ], + preTokenBalances: [ + { + accountIndex: 2, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), + owner: address('EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '10000000' as StringifiedBigInt, + decimals: 9, + uiAmount: 0.01, + uiAmountString: '0.01' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 336671683n, + transaction: { + message: { + accountKeys: [ + address('EMmTjuHsYCYX7vgPcQ2QVbNwYAwcvGoSMCEaHKc19DdE'), + address('5k4KRS1HVR5DaQhPzY3P9mRBw1k3TK7fug23utRcqgk1'), + address('BDAg5uPZ6ktjPZuKoktHLYbJLw6KqRTiHtJoeG9GDyLY'), + address('11111111111111111111111111111111'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('BYh4CfuGDvFMKaZp3RPmkw9y6qg3sWukA2TiGJDeLKZi'), + address('ComputeBudget111111111111111111111111111111'), + address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), + address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + ] as Address[], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 6, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3GAG5eogvTjV' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [0, 1, 5, 7, 3, 8], + data: '2' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [2, 1, 0], + data: '3ay2hEw4e3yH' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: null, + }, + { + accounts: [], + data: 'JPdU3Z' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'GgifyvZfDFMQZmwmejZv7WuW7yWPFdo2jGrw2hb3Pba4', + ), + }, + signatures: [ + '4G24SgaZ3gU92HAB8xwSVg6WXS7NcGtUpHMnQ5RTwBw9bG5x8y6co5TzqqPXbExovY2NAuPjE9393TCHFZVhS8K9', + ] as Base58EncodedBytes[], + }, + version: 0, + }; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self-2.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self-2.ts new file mode 100644 index 00000000..d8ec9b91 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self-2.ts @@ -0,0 +1,206 @@ +import { address, blockhash, lamports } from '@solana/kit'; +import type { + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, +} from '@solana/kit'; +import type { UnixTimestamp } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +export const EXPECTED_SEND_USDC_TRANSFER_TO_SELF_2_DATA: SolanaTransaction = { + blockTime: 1747059490n as UnixTimestamp, + meta: { + computeUnitsConsumed: 26996n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5270n), + innerInstructions: [ + { + index: 1, + instructions: [ + { + accounts: [7], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + { + accounts: [0, 1], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: 2, + }, + { + accounts: [1], + data: 'P' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + { + accounts: [1, 7], + data: '6VnvmvXDQrwarVifa4tnxK6ReroTHnai7MRaDpcE2rAyg' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1622 of 19863 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 13223 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4241 of 9339 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 22052 of 26846 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 4794 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + ], + postBalances: [ + lamports(2090412273n), + lamports(2039280n), + lamports(2039280n), + lamports(1n), + lamports(0n), + lamports(731913600n), + lamports(1n), + lamports(390010697122n), + lamports(934087680n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('9fE6zKgca6K2EEa3yjbcq7zGMusUNqSQeWQNL2YDZ2Yi'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1000000' as StringifiedBigInt, + decimals: 6, + uiAmount: 1.0, + uiAmountString: '1' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '8405777' as StringifiedBigInt, + decimals: 6, + uiAmount: 8.405777, + uiAmountString: '8.405777' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(2092456823n), + lamports(0n), + lamports(2039280n), + lamports(1n), + lamports(0n), + lamports(731913600n), + lamports(1n), + lamports(390010697122n), + lamports(934087680n), + ], + preTokenBalances: [ + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '9405777' as StringifiedBigInt, + decimals: 6, + uiAmount: 9.405777, + uiAmountString: '9.405777' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 339540943n, + transaction: { + message: { + accountKeys: [ + address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + address('FrbudWWrsiv5deKG483U91njmeFPV45s3DQUfjhDD2BZ'), + address('JBoHYN5t2NW6fUwDNXyYdQjKwRea6pMkRNqBSB2nFxUH'), + address('11111111111111111111111111111111'), + address('9fE6zKgca6K2EEa3yjbcq7zGMusUNqSQeWQNL2YDZ2Yi'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('ComputeBudget111111111111111111111111111111'), + address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 6, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3GAG5eogvTjV' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [0, 1, 4, 7, 3, 8], + data: '2' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + { + accounts: [2, 1, 0], + data: '3QCwqmHZ4mdq' as Base58EncodedBytes, + programIdIndex: 8, + stackHeight: null, + }, + { + accounts: [], + data: 'H4eVYK' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '41mww1wbRbSjvBaXwue59UpGr2K58Kxf8Zq59wgo7b5w', + ), + }, + signatures: [ + 'LPaVYsnhx2q9yTeU7bf5vLESBdm6BYatMSdZqt6CYy8wcX5YFm6rNKZLXJqRA7jyq2w3nbEqfB4qgCFSGS1L6GT' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self.ts new file mode 100644 index 00000000..1131dc35 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer-to-self.ts @@ -0,0 +1,125 @@ +import type { + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Send USDC to self + * Transaction: fFSAjDzu7CdhzVUUC7DMKf7xuuVn8cZ8njPnpjkTBMHo4Y43SZto2GDuy123yKDoTieihPfDHvBpysE7Eh9aPmH + * + * Senders: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP sends 1 USDC to self + * + * Receivers: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP receives 1 USDC (self-transfer) + */ +export const EXPECTED_SEND_USDC_TRANSFER_TO_SELF_DATA: SolanaTransaction = { + blockTime: 1741796354n as UnixTimestamp, + meta: { + computeUnitsConsumed: 4524n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4374 of 4374 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(2853640269n), + lamports(2039280n), + lamports(1n), + lamports(934087680n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7606876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.606876, + uiAmountString: '7.606876' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(2853645269n), + lamports(2039280n), + lamports(1n), + lamports(934087680n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7606876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.606876, + uiAmountString: '7.606876' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 366862348n, + transaction: { + message: { + accountKeys: [ + address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + address('G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA'), + address('ComputeBudget111111111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'JVAEwh' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: null, + }, + { + accounts: [1, 1, 0], + data: '3QCwqmHZ4mdq' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'CAV8KMEh8AAacttd6Hdya1DJhDbuztFtdVsz6qHUx7EM', + ), + }, + signatures: [ + 'fFSAjDzu7CdhzVUUC7DMKf7xuuVn8cZ8njPnpjkTBMHo4Y43SZto2GDuy123yKDoTieihPfDHvBpysE7Eh9aPmH', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer.ts new file mode 100644 index 00000000..7bdb9c35 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/send-usdc-transfer.ts @@ -0,0 +1,142 @@ +import type { + Address, + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Send USDC + * Transaction: 3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV + * + * Senders: + * BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP sends 0.01 USDC + * + * Receivers: + * BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe gets 0.01 USDC + */ +export const EXPECTED_SEND_USDC_TRANSFER_DATA: SolanaTransaction = { + blockTime: 1736502537n as UnixTimestamp, + meta: { + computeUnitsConsumed: 4644n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 200000 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(3283860040n), + lamports(2039280n), + lamports(2039280n), + lamports(934087680n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '70000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.07, + uiAmountString: '0.07' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7649876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.649876, + uiAmountString: '7.649876' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(3283865040n), + lamports(2039280n), + lamports(2039280n), + lamports(934087680n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BXT1K8kzYXWMi6ihg7m9UqiHW4iJbJ69zumELHE9oBLe'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '60000' as StringifiedBigInt, + decimals: 6, + uiAmount: 0.06, + uiAmountString: '0.06' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), + owner: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7659876' as StringifiedBigInt, + decimals: 6, + uiAmount: 7.659876, + uiAmountString: '7.659876' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 353107528n, + transaction: { + message: { + accountKeys: [ + 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + '644PJ6UW8e4gQpjKdBVd4MCYasWjSECKjqd2qzdeAJY6', + 'G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + ] as Address[], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [2, 1, 0], + data: '3GAG5eogvTjV' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'B7QWXjTeXd5kPbKc4ASH56f2EPUjLKW4J5CtNL5iS6GW', + ), + }, + signatures: [ + '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam-2.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam-2.ts new file mode 100644 index 00000000..2359b4d1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam-2.ts @@ -0,0 +1,290 @@ +import type { Base58EncodedBytes, Lamports, UnixTimestamp } from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Spam transaction with many small transfers + * Transaction: 4Ud63HtJxynBEbuiUjNPvKudhzZSF93MS2qi8JRq8u3hakogcQBxUXRdC1w4uBaciKAVDrhWqpGS4TvW81WE3T3r + */ +export const EXPECTED_SPAM_TRANSACTION_DATA_2: SolanaTransaction = { + blockTime: 1745575958n as UnixTimestamp, + meta: { + computeUnitsConsumed: 3000n, + // eslint-disable-next-line id-denylist + err: { + InsufficientFundsForRent: { + account_index: 2, + }, + }, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [ + 3118296904n as Lamports, + 6995000n as Lamports, + 0n as Lamports, + 80816719n as Lamports, + 647307812n as Lamports, + 304658522n as Lamports, + 13103784568n as Lamports, + 134518579n as Lamports, + 255009189n as Lamports, + 95726574n as Lamports, + 356881262n as Lamports, + 53273314n as Lamports, + 480945119062n as Lamports, + 14804350n as Lamports, + 22749657n as Lamports, + 1060000n as Lamports, + 17177272n as Lamports, + 4995000n as Lamports, + 6995000n as Lamports, + 97764461n as Lamports, + 80992561n as Lamports, + 1n as Lamports, + ], + postTokenBalances: [], + preBalances: [ + 3118301904n as Lamports, + 6995000n as Lamports, + 0n as Lamports, + 80816719n as Lamports, + 647307812n as Lamports, + 304658522n as Lamports, + 13103784568n as Lamports, + 134518579n as Lamports, + 255009189n as Lamports, + 95726574n as Lamports, + 356881262n as Lamports, + 53273314n as Lamports, + 480945119062n as Lamports, + 14804350n as Lamports, + 22749657n as Lamports, + 1060000n as Lamports, + 17177272n as Lamports, + 4995000n as Lamports, + 6995000n as Lamports, + 97764461n as Lamports, + 80992561n as Lamports, + 1n as Lamports, + ], + preTokenBalances: [], + rewards: [], + status: { + Err: { + InsufficientFundsForRent: { + account_index: 2, + }, + }, + }, + }, + slot: 335785189n, + transaction: { + message: { + accountKeys: [ + address('FLiPgGTXtBtEJoytikaywvWgbz5a56DdHKZU72HSYMFF'), + address('3S8jtwoRCqJU9MzSHDhCaGSZS1SuFwMgRYBmXuUYFd3w'), + address('4m1Pvj5hKohfKnPui29dt2ADmEHvT6vjGFfSsPyrTTye'), + address('5ACmnJUaixG9tC2Cd9y43nopsQrQ2fAQX1pnkjY4G3KR'), + address('5i4rYeuN3kEJY7u15ZLEGHfiqZw6HGZfoQePCa74EemW'), + address('5NvEdsaZWEvo927gXRAMX8mMobV4ifMLww1SzTuALW1S'), + address('5u484B5fdNHJDmuKYnvvAzUiEsKEDC95XvX6ZSAyk69t'), + address('5wvFjNejSpdEa1DQisWsd9y38AatDqGL9EygyTeyRirr'), + address('6CEeJBSumMVJXcjhemqMeGE8pJJ5JzEhL3zZhLr9RrjW'), + address('7B5oU9osHxfRaZtBFjbTTHumBy7zpGXn5pWwgv9vCk4q'), + address('8EgaLvYAQNDGuBspMLiv8McYohSR8uqAqG8MEhbhZMaf'), + address('9CNomhzGfcFR6tY86bWjhET7mgvyuAw66XCK2XTTLu78'), + address('9fLSiaHXA5SFKZ8vRr87boKuogTmXKFei6XPwoVS7WUp'), + address('AHJMt3Nm2eAu2eFqvjnmUFC7r1tetA4vbuF3dVceosq8'), + address('ALxSgEgq8ieTTwjmuMAUr2H5PFvUyc7Ffs6b61FQB6wN'), + address('AT5WnESNWj2hCnf2ZMWDfVi92mgrCG4hRfZrfGtBryoA'), + address('Bm57vLEUtKxqFiFfK96MArCFoLmBn1D7PB824e8AP6wL'), + address('EjX9MJ6dgJZPJrAyDn6gd5DooVjLnpJ4C8SoaJunpQcY'), + address('EqgotNrkRZ5afKDw7vHYH6tcCuayXfjQRaHpgsBdK99H'), + address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + address('HqrjuY5TT4wmei5FAutX2k4oboQeUtX5jmHZPXvkJBjh'), + address('11111111111111111111111111111111'), + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [0, 7], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 20], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 8], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 10], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 6], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 4], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 18], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 13], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 2], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 16], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 5], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 15], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 9], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 12], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 11], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 14], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 19], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 17], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 3], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '8ehgHhVQm4Ug3B8Ggs5QzRrsRANY1LDb87Z7EpL4Kayi', + ), + }, + signatures: [ + '4Ud63HtJxynBEbuiUjNPvKudhzZSF93MS2qi8JRq8u3hakogcQBxUXRdC1w4uBaciKAVDrhWqpGS4TvW81WE3T3r' as Base58EncodedBytes, + ], + }, + version: 'legacy', +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam.ts new file mode 100644 index 00000000..cd0e4a09 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/spam.ts @@ -0,0 +1,304 @@ +import type { + Address, + Base58EncodedBytes, + Lamports, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Devnet - Spam transaction with many small transfers + * Transaction: 5LUArY58kJQLtdrc1A42rcr3qJRTmuZdK8aPC9Beme9PuKom5qNPkxyBhsz6JDHrRff428doMLb77fdbw1C7uwYD + */ +export const EXPECTED_SPAM_TRANSACTION_DATA: SolanaTransaction = { + blockTime: 1744719251n as UnixTimestamp, + meta: { + computeUnitsConsumed: 3300n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(5825n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [ + lamports(3024929425n), + lamports(43482164n), + lamports(153903900n), + lamports(46307307n), + lamports(500000001n), + lamports(103887324n), + lamports(1500002n), + lamports(155147992n), + lamports(425208225n), + lamports(221453134n), + lamports(12485028n), + lamports(2955158n), + lamports(16611289n), + lamports(365606093n), + lamports(124095975n), + lamports(2955158n), + lamports(12502825n), + lamports(1093552237n), + lamports(2955158n), + lamports(342909847n), + lamports(6728730n), + lamports(1n), + lamports(1n), + ] as Lamports[], + postTokenBalances: [], + preBalances: [ + lamports(3024935270n), + lamports(43482163n), + lamports(153903899n), + lamports(46307306n), + lamports(500000000n), + lamports(103887323n), + lamports(1500001n), + lamports(155147991n), + lamports(425208224n), + lamports(221453133n), + lamports(12485027n), + lamports(2955157n), + lamports(16611288n), + lamports(365606092n), + lamports(124095974n), + lamports(2955157n), + lamports(12502824n), + lamports(1093552236n), + lamports(2955157n), + lamports(342909846n), + lamports(6728729n), + lamports(1n), + lamports(1n), + ] as Lamports[], + preTokenBalances: [], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 333623464n, + transaction: { + message: { + accountKeys: [ + address('5Hr7wZg7oBpVhH5nngRqzr5W7ZFUfCsfEhbziZJak7fr'), + address('2pVn4ysAUNTuHPPD1Ew31LUpCxUvjLAv2HBHoVT9zAyV'), + address('5hQoqsJFmAfJ56kdYwvKBsjrjk6dSgJrVFffMBREdsKc'), + address('5MfXAXwzAZe8ZXykgQsyQJNug16EqGSZwSUcXUvQGF2E'), + address('6sPVFUr8cp2QcrZFagKtLZi8zYddocAaRe8fo84K9ANy'), + address('8XjdTBGJFdm99UUJHK5d6wWgk7YUTcht1893GVFj9JBq'), + address('9D3B1qqMvo75embthBkY6kzD2US8wXC9ZQziYidGMe2r'), + address('AjPDra6NqqrQjhF38kpFv5Gmz7WpJKowGJz8HU8Gst1U'), + address('AMcirPqxyiMcr6zQYcm12d7jqwwKW4UWrz3YLkVU1PGx'), + address('AuDRtrMns4JQAG7YLWvotkRC7R3dLAFktpkVHsQZ4Hr4'), + address('ChCEoVFzxwYJQTr7nP82XwRuN3Roup8Gg7aN2NRy2b5X'), + address('CZUZ2t4HrenwX68u4svUXwRFenBTdcpHU8VMxVCRLoUp'), + address('DAXnAudMEqiD1sS1rFn4ds3pdybRYJd9J58PqCncVVqS'), + address('DBvvqLFjReM5jccKcXE3aLfPo4igKv2tQ2ThbeCoSyAP'), + address('DkUVuMJdTSNa6DuKPu6fVXHzPs7MKxqjWJ1cBs4QQ1ky'), + address('DYGM5xtmCNBpNGYn8KzxYLQ4UFKMEFgsHCsyCpqwk3bN'), + address('EQGq3qQ9xYbBbGsapzfLhK6tTaK3hE8yKpjwmhdXnmvS'), + address('FEirXPneuzCJLRExAWcWaineTh4BA3jmE5wetuXodWTX'), + address('GogXCV9bnX97UywkxvtBvfYCPkZMFjiDNuhZDFypDc89'), + address('HAxAR7uH9aC7cXpjWgMW6dQdSyWGJiZ9Yq7HbwQJF1mY'), + address('HghUJbvQxgyX8jCiyqGjAQbYtxzdbrzfHWNoaEh7e1e4'), + address('11111111111111111111111111111111'), + address('ComputeBudget111111111111111111111111111111'), + ] as Address[], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [] as number[], + data: '3dgRf8s6ueV5' as Base58EncodedBytes, + programIdIndex: 22, + stackHeight: null, + }, + { + accounts: [] as number[], + data: 'Kv9sKM' as Base58EncodedBytes, + programIdIndex: 22, + stackHeight: null, + }, + { + accounts: [0, 7], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 16], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 14], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 8], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 6], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 3], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 19], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 20], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 12], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 17], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 11], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 10], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 15], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 13], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 4], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 5], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 18], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 9], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [0, 2], + data: '3Bxs412MvVNQj175' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '2idM614hPTPawAABsNXPBTbjFfTUjSLokFULP7SgXrjP', + ), + }, + signatures: [ + '5LUArY58kJQLtdrc1A42rcr3qJRTmuZdK8aPC9Beme9PuKom5qNPkxyBhsz6JDHrRff428doMLb77fdbw1C7uwYD', + ] as Base58EncodedBytes[], + }, + version: 'legacy', +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-a16z-usdt-sol.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-a16z-usdt-sol.ts new file mode 100644 index 00000000..13b49ee6 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-a16z-usdt-sol.ts @@ -0,0 +1,559 @@ +import type { + Address, + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Swap A16Z -> USDT -> SOL via Jupiter Aggregator V6 + * Transaction: JiqYGkWcYu8GxPZsMdXDnA8tkZvHnHVmNuKr4JYBErm4rgQWssdHCkbe8MzwwNGndyvyNYaaY5vvMhUMPNiQX9u + * + * Accounts involved: + * - FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6: User wallet + * - Jupiter V6 Program: routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS + * - Various AMM pools and token accounts involved in the swap route. + */ +export const EXPECTED_SWAP_A16Z_USDT_SOL_DATA: SolanaTransaction = { + blockTime: 1745425114n as UnixTimestamp, + meta: { + computeUnitsConsumed: 242108n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(22342n), + innerInstructions: [ + { + index: 0, // Means that these are the inner instructions for transaction.message.instructions[0] + instructions: [ + // #1.1 - Raydium Concentrated Liquidity: SwapV2 + { + accounts: [0, 18, 7, 15, 5, 8, 14, 1, 30, 31, 25, 22, 20, 16, 6, 9], + data: 'ASCsAbe1UnEFMJQbPShDspA4Mtrgik54fGtKfUGEGt7vQU77d7XfBrbz' as Base58EncodedBytes, + programIdIndex: 29, + stackHeight: 2, + }, + // #1.2 - Token 2022 Program: TransferChecked + { + accounts: [15, 22, 8, 0], + data: 'hHi4Sy5CUXcNU' as Base58EncodedBytes, + programIdIndex: 31, + stackHeight: 3, + }, + // #1.3 - Token Program: TransferChecked + { + accounts: [14, 20, 5, 7], + data: 'hvZoChyXb9GHj' as Base58EncodedBytes, + programIdIndex: 30, + stackHeight: 3, + }, + // #1.4 - Associated Token Account Program: Create + { + accounts: [0, 17, 0, 23, 27, 30], + data: '1' as Base58EncodedBytes, // '0' for 'Create' and '1' for 'CreateIdempotent' + programIdIndex: 28, // Associated Token Account Program + stackHeight: 2, + }, + // #1.5 - Token Program: GetAccountDataSize + { + accounts: [23], // WSOL Mint + data: '84eT' as Base58EncodedBytes, + programIdIndex: 30, // Token Program + stackHeight: 3, + }, + // #1.6 - System Program: CreateAccount + { + accounts: [0, 17], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 27, // System Program + stackHeight: 3, + }, + // #1.7 - Token Program: InitializeImmutableOwner + { + accounts: [17], + data: 'P' as Base58EncodedBytes, + programIdIndex: 30, // Token Program + stackHeight: 3, + }, + // #1.8 - Token Program: InitializeAccount3 + { + accounts: [17, 23], + data: '6bY9pNeU2qwkJgSyHZ9tDZHS8MKeAVk2hkGBGD7vwkPE4' as Base58EncodedBytes, + programIdIndex: 30, // Token Program + stackHeight: 3, + }, + // #1.9 - Raydium Concentrated Liquidity: SwapV2 + { + accounts: [ + 0, 19, 21, 5, 17, 2, 10, 3, 30, 31, 25, 20, 23, 4, 11, 13, 12, + ], + data: 'ASCsAbe1UnEWBUtPg3Atg89hojMN2wioE7NPdqjnx7TMCWqZ1nvkcNaC' as Base58EncodedBytes, + programIdIndex: 29, + stackHeight: 2, + }, + // #1.10 - Token Program: TransferChecked + { + accounts: [5, 20, 2, 0], + data: 'hvZoChyXb9GHj' as Base58EncodedBytes, + programIdIndex: 30, + stackHeight: 3, + }, + // #1.11 - Token Program: TransferChecked + { + accounts: [10, 23, 17, 21], + data: 'gDAuq2Hq93qJY' as Base58EncodedBytes, + programIdIndex: 30, + stackHeight: 3, + }, + ], + }, + { + index: 1, // Means that these are the inner instructions for transaction.message.instructions[1] + instructions: [ + // #2.1 - Token Program: CloseAccount + { + accounts: [17, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 30, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('11111111111111111111111111111111'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + ] as Address[], + writable: [] as Address[], + }, + logMessages: [ + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS invoke [1]', + 'Program log: process_swap_base_in_with_user_account:RouteSwapBaseInArgs { amount_in: 21898077, minimum_amount_out: 28017 }', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK invoke [2]', + 'Program log: Instruction: SwapV2', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb consumed 2456 of 180764 compute units', + 'Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 174335 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program data: QMbN6CYIceIy+VeK7Au3qP50Lrwtv7uwHdH4xQ+z0Xeka0TEPG8rytYFidOEwxq1G3dAz8FbZnBrVGJqOoS/llPmNsEZAQ7FLHM8VXURkc4gyDrjlDFQObpDo3uak0HiKoZIdhklseqlkOi1LRzjiyuwA2EANVitiSEvYYy1jzRrpbfRje6Ch48QAAAAAAAAAAAAAAAAAABdI04BAAAAAAAAAAAAAAAAACO/h4hC1uDeRwAAAAAAAABD804SAAAAAAAAAAAAAAAA/U0BAA==', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK consumed 80238 of 241826 compute units', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK success', + 'Program log: 21898077 -> 4239', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [2]', + 'Program log: Create', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 146424 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [3]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 139837 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 135955 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 19307 of 151821 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK invoke [2]', + 'Program log: Instruction: SwapV2', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 62483 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 52310 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program data: QMbN6CYIceLPZqd3cc+gFFF/2cRVEgwL+6XlbKisjoCilx8qb/dSm9YFidOEwxq1G3dAz8FbZnBrVGJqOoS/llPmNsEZAQ7FqmxvO7L2St3gqkrUz9uoFap7gJpI0DPYgWRm3HmP1lUsczxVdRGRziDIOuOUMVA5ukOje5qTQeIqhkh2GSWx6ghuAAAAAAAAAAAAAAAAAACPEAAAAAAAAAAAAAAAAAAAAAek/1ABHExjAAAAAAAAAAC3URk9WQAAAAAAAAAAAAAAAbb//w==', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK consumed 76582 of 116092 compute units', + 'Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK success', + 'Program log: 4239 -> 28168', + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS consumed 234315 of 270090 compute units', + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS success', + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS invoke [1]', + 'Program log: process_close_token_account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 31490 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS consumed 7493 of 35775 compute units', + 'Program routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + ], + postBalances: [ + lamports(163148592n), + lamports(32092560n), + lamports(2039680n), + lamports(32092960n), + lamports(13642000n), + lamports(2039280n), + lamports(72161280n), + lamports(11637120n), + lamports(2039280n), + lamports(72161280n), + lamports(263336368076n), + lamports(72161280n), + lamports(72161280n), + lamports(72161280n), + lamports(2039280n), + lamports(2074080n), + lamports(13641600n), + lamports(0n), + lamports(1705200n), + lamports(1705500n), + lamports(114473352734n), + lamports(13645861n), + lamports(18359024537n), + lamports(1028469402250n), + lamports(1n), + lamports(521498880n), + lamports(1141440n), + lamports(1n), + lamports(731913600n), + lamports(1141440n), + lamports(934087680n), + lamports(1141440n), + ], + postTokenBalances: [ + { + accountIndex: 2, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT + owner: address('ExcBWu8fGPdJiaF1b1z3iEef38sjQJks8xvj6M85pPY6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '24814019869' as StringifiedBigInt, + decimals: 6, + uiAmount: 24814.019869, + uiAmountString: '24814.019869' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), // User wallet + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, // User received 28168 USDT smallest units (0.028168 USDT) + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), // A16Z + owner: address('4RyuSgGdYeE7Qd4eKsuauTevWRreSfGScYqaygMEJXSq'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '51518656793' as StringifiedBigInt, + decimals: 9, + uiAmount: 51.518656793, + uiAmountString: '51.518656793' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: address('So11111111111111111111111111111111111111112'), // WSOL + owner: address('ExcBWu8fGPdJiaF1b1z3iEef38sjQJks8xvj6M85pPY6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '263334328396' as StringifiedBigInt, + decimals: 9, + uiAmount: 263.334328396, + uiAmountString: '263.334328396' as StringifiedNumber, + }, + }, + { + accountIndex: 14, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT + owner: address('4RyuSgGdYeE7Qd4eKsuauTevWRreSfGScYqaygMEJXSq'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '10667087' as StringifiedBigInt, + decimals: 6, + uiAmount: 10.667087, + uiAmountString: '10.667087' as StringifiedNumber, + }, + }, + { + accountIndex: 15, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), // A16Z + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), // User wallet + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '21898077' as StringifiedBigInt, // User had 43796154, sent 21898077, remaining 21898077 + decimals: 9, + uiAmount: 0.021898077, + uiAmountString: '0.021898077' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(163142766n), + lamports(32092560n), + lamports(2039680n), + lamports(32092960n), + lamports(13642000n), + lamports(2039280n), + lamports(72161280n), + lamports(11637120n), + lamports(2039280n), + lamports(72161280n), + lamports(263336396244n), + lamports(72161280n), + lamports(72161280n), + lamports(72161280n), + lamports(2039280n), + lamports(2074080n), + lamports(13641600n), + lamports(0n), + lamports(1705200n), + lamports(1705500n), + lamports(114473352734n), + lamports(13645861n), + lamports(18359024537n), + lamports(1028469402250n), + lamports(1n), + lamports(521498880n), + lamports(1141440n), + lamports(1n), + lamports(731913600n), + lamports(1141440n), + lamports(934087680n), + lamports(1141440n), + ], + preTokenBalances: [ + { + accountIndex: 2, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT Mint + owner: address('ExcBWu8fGPdJiaF1b1z3iEef38sjQJks8xvj6M85pPY6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '24814015630' as StringifiedBigInt, + decimals: 6, + uiAmount: 24814.01563, + uiAmountString: '24814.01563' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT Mint + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), // User wallet + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), // A16Z Mint + owner: address('4RyuSgGdYeE7Qd4eKsuauTevWRreSfGScYqaygMEJXSq'), + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '51496758716' as StringifiedBigInt, + decimals: 9, + uiAmount: 51.496758716, + uiAmountString: '51.496758716' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: address('So11111111111111111111111111111111111111112'), // WSOL Mint + owner: address('ExcBWu8fGPdJiaF1b1z3iEef38sjQJks8xvj6M85pPY6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '263334356564' as StringifiedBigInt, + decimals: 9, + uiAmount: 263.334356564, + uiAmountString: '263.334356564' as StringifiedNumber, + }, + }, + { + accountIndex: 14, + mint: address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // USDT Mint + owner: address('4RyuSgGdYeE7Qd4eKsuauTevWRreSfGScYqaygMEJXSq'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '10671326' as StringifiedBigInt, + decimals: 6, + uiAmount: 10.671326, + uiAmountString: '10.671326' as StringifiedNumber, + }, + }, + { + accountIndex: 15, + mint: address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), // A16Z Mint + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), // User wallet + programId: address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), + uiTokenAmount: { + amount: '43796154' as StringifiedBigInt, // User has 0.043796154 A16Z + decimals: 9, + uiAmount: 0.043796154, + uiAmountString: '0.043796154' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 335404896n, + transaction: { + message: { + accountKeys: [ + address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), // Index 0. User wallet + address('2omU3uDsWJJjcM7XZ8BrELuN1RysYk9Gc3pYUZNTYav4'), // Index 1. + address('2w6WUGXVeUPeGpJTUSNu4btbpMkFtmjrQgpKi149it7K'), // Index 2. + address('3AsJZQY3jtZwqBfbTUQ4xKDVwmdz2ufdKMsnxN9K5L3S'), // Index 3. + address('3xVTGrrbWLtPC2qRiHyiVbKEDY2UUYN9kwJWmPH2n8wW'), // Index 4. + address('3zWrWv3hu6fJfqpZech7sPLrF4Lm7qx1mmfMaHdJZyTP'), // Index 5. User USDT ATA + address('4odFEFnsNWHdj7P7RZ7fVWS86NJyB1LVG8U1Bec1eJSD'), // Index 6. + address('4RyuSgGdYeE7Qd4eKsuauTevWRreSfGScYqaygMEJXSq'), // Index 7. + address('5M3c33wKLoPRBUN24dCp9xrug6RvgB57ccQAwsmHBtvn'), // Index 8. + address('6B9u2WAAqr7GExuCPfXQ159EeAoebtJKEawr84GVoy1n'), // Index 9. + address('7CoLodcm9h4EAFKpcPbT34yk3MkpeWjsBqXVidfbrfQ7'), // Index 10. + address('9dz29uZsFQWkwgMGcPYqrWo8DiGjErDo7uNULWRGfNR8'), // Index 11. + address('9KgS9EwmhKp1aiGHYXFLv3qSgp2HHtFtNP5tKsVRDCp2'), // Index 12. + address('AtTKYTWtb2nPF7B9RUYb3FXQn8Er9bCFnupMsXy5MKci'), // Index 13. + address('BfrpfwfvvRbswT5jPqy4Ey9iek9PsBCXssF4hCa4UZ7u'), // Index 14. + address('C9JRr8XMCatQ9kSii8VNHLMHQWfcA1pHCzhR5M1xAYUv'), // Index 15. User A16Z ATA + address('ChSvYbQCjvpHhCsZRm8rFJGbSWYE6csJp1bNsTrYZtNS'), // Index 16. + address('CUGD6t9tqqdQnq35Cr28S3yQLxw3ecLGSGgk6HoSxtDA'), // Index 17. User WSOL ATA. Is created to temporarily hold the WSOLs from the swap. Is closed at the end of the transaction and all WSOLs are redeemed to the user wallet as SOL. + address('DrdecJVzkaRsf1TQu1g7iFncaokikVTHqpzPjenjRySY'), // Index 18. + address('EdPxg8QaeFSrTYqdWJn6Kezwy9McWncTYueD9eMGCuzR'), // Index 19. + address('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), // Index 20. USDT Mint + address('ExcBWu8fGPdJiaF1b1z3iEef38sjQJks8xvj6M85pPY6'), // Index 21. User wallet + address('HeLp6NuQkmYB4pYWo2zYs22mESHXPQYzXbB8n4V98jwC'), // Index 22. A16Z Mint + address('So11111111111111111111111111111111111111112'), // Index 23. WSOL Mint + address('ComputeBudget111111111111111111111111111111'), // Index 24. + address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), // Index 25. + address('routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS'), // Index 26. Jupiter V6 Router + // Lookup Table accounts start here (indices relative to lookup table, not accountKeys) + address('11111111111111111111111111111111'), // Index 27. System Program + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), // Index 28. Associated Token Account Program + address('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'), // Index 29. Orca Whirlpool Program (LUT index 2) + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), // Index 30. Token Program (LUT index 3) + address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'), // Index 31. Token Extensions Program (LUT index ?) - This seems missing in LUT data but used by inner instructions + ] as Address[], + addressTableLookups: [ + { + accountKey: address('2immgwYNHBbyVQKVGCEkgWpi53bLwWNRMB5G2nbgYV17'), + readableIndexes: [0, 1, 2, 3, 7, 16], // Corrected based on lookup table usage (indices are for LUT content) + writableIndexes: [], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 3, // FQT9SS... is signer, others are readonly + numRequiredSignatures: 1, + }, + instructions: [ + // Jupiter V6 instruction + // #1 - Raydium AMM Routing: Unknown + { + accounts: [ + 30, // Token Program + 31, // Token Extensions Program + 28, // Associated Token Account Program + 27, // System Program + 0, // User Wallet (signer)/ + 15, // User A16Z ATA (source) + 17, // User WSOL ATA + 29, // Orca Whirlpool Program + 5, // User USDT ATA (owner for creation check?) - Seems redundant + 22, // A16Z Mint + 20, // USDT Mint + 18, // Orca Whirlpool A16Z/USDC Pool State + 7, // Orca Pool A16Z Vault + 8, // Orca Pool A16Z Vault ATA? (derived) + 14, // Orca Pool USDT Vault + 1, // Orca Pool USDT Vault ATA? (derived) + 25, // Memo Program + 16, // Orca Whirlpool WSOL/USDC Pool State + 6, // Orca Pool WSOL Vault + 9, // Orca Pool WSOL Vault ATA? (derived) + // Duplicated accounts for second leg of swap? + 29, // Orca Whirlpool Program + 17, // User WSOL ATA (now exists) + 20, // USDT Mint + 23, // WSOL Mint + 19, // Orca Whirlpool USDT/WSOL Pool State? + 21, // Orca Pool USDT Vault + 2, // Orca Pool USDT Vault ATA? (derived) + 10, // Orca Pool WSOL Vault + 3, // Orca Pool WSOL Vault ATA? (derived) + 25, // Memo Program + 4, // Some other Orca account? + 11, // Some other Orca account? + 13, // Some other Orca account? + 12, // Some other Orca account? + ], + data: '14RAD42BejNvLRNHtxMURkEQqVc63YKJdsQLhJdCPt3y9WaCEf9nWn29megzLgkjoRy' as Base58EncodedBytes, + programIdIndex: 26, // Jupiter V6 Router + stackHeight: null, + }, + { + // Jupiter V6 close empty ATA instruction + // #2 - Raydium AMM Routing: Unknown + accounts: [ + 0, // User Wallet (rent receiver) + 17, // User WSOL ATA + 0, // Owner (user wallet again) + 30, // Token Program + 28, // Associated Token Account Program + 27, // System Program + ], + data: '7' as Base58EncodedBytes, + programIdIndex: 26, // Jupiter V6 Router + stackHeight: null, + }, + // #3 - Compute Budget: SetComputeUnitPrice + { + accounts: [], + data: '3ounyg1gTkfR' as Base58EncodedBytes, + programIdIndex: 24, // Compute Budget Program + stackHeight: null, + }, + // #4 - Compute Budget: SetComputeUnitLimit + { + accounts: [], + data: 'EM4xLX' as Base58EncodedBytes, + programIdIndex: 24, // Compute Budget Program + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'FB1pi3RABPcFSam7o187Br6r6Tr7LUmN3GaoHJYEhEot', + ), + }, + signatures: [ + 'JiqYGkWcYu8GxPZsMdXDnA8tkZvHnHVmNuKr4JYBErm4rgQWssdHCkbe8MzwwNGndyvyNYaaY5vvMhUMPNiQX9u', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-failed-transaction.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-failed-transaction.ts new file mode 100644 index 00000000..d0559e2a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-failed-transaction.ts @@ -0,0 +1,427 @@ +import type { + Address, + Base58EncodedBytes, + Slot, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Failed Swap + * Transaction: 58FymkjJUeSFGeEdaUQZbhHP5tdwwvbRR8BfKfuEgfYznqDqsApRBk8LCtiKny9EjQZBNi5NxGvLjR6F3gY6rxn1 + * + * Senders: + * + * Receivers: + */ +export const EXPECTED_SWAP_FAILED_TRANSACTION_DATA: SolanaTransaction = { + blockTime: 1741949141n as UnixTimestamp, + meta: { + computeUnitsConsumed: 104305n, + // eslint-disable-next-line id-denylist + err: { InstructionError: [2, { Custom: 6001 }] }, + fee: lamports(5146n), + innerInstructions: [ + { + index: 2, + instructions: [ + { + accounts: [19, 9, 20, 9, 10, 11, 9, 9, 9, 9, 9, 9, 9, 9, 2, 1, 0], + data: '6BKGqBSufxSksaFn6hyPx7y' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + { + accounts: [2, 10, 0], + data: '3asRj1HGdfcj' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 3, + }, + { + accounts: [11, 1, 20], + data: '3fc5BGrysRgT' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 3, + }, + { + accounts: [8], + data: 'QMqFu4fYGGeUEysFnenhAvR83g86EDDNxzUskfkWKYCBPWe1hqgD6jgKAXr6aYoEQaxoqYMTvWgPVk2AHWGHjdbNiNtoaPfZA4znu6cRUSWSeNvfLkckkHKqHjKCnJkxLXV9QwBLjkhoGQMnnFPa9iPrJDN5T49CBrqKXkkitM11EVM' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: 2, + }, + { + accounts: [ + 13, 22, 16, 12, 1, 2, 23, 18, 17, 22, 0, 19, 19, 24, 22, 3, 14, + 15, + ], + data: 'PgQWtn8ozixA6EmPobjQWbAuEjTkWPn1D' as Base58EncodedBytes, + programIdIndex: 22, + stackHeight: 2, + }, + { + accounts: [1, 23, 16, 0], + data: 'i6MX5fxEonuqF' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 3, + }, + { + accounts: [12, 18, 2, 13], + data: 'gN473h1hKRCjS' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: 3, + }, + { + accounts: [24], + data: 'yCGxBopjnVNQkNP5usq1PoUkBBY8efS9eeBA1qhuF9ePgc1cABPtx5bsdarGDujyQLmAHAjMZALUwa5hQXdu6qFTrdgpemWGFDEWPuEcyXjyHyJowWY4Z71oiHHU4QHHabd4cMWc2ShGcVxmeHyWSut8xFgjyWAX23EMDqet4ACZeztUBT9Czou2fGeAZCzaXRauaF' as Base58EncodedBytes, + programIdIndex: 22, + stackHeight: 3, + }, + { + accounts: [8], + data: 'QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VKRCxxLHPz7xKMZ6b7SFpnjHpir89pgVG9kwtsnYkobbEF4E1hjvE4LSPQcDFSW3QCZG23k3j82aB5Kc9wgXBdBiYHyMBeemVHsBhKJ5YVSRyaP' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('So11111111111111111111111111111111111111112'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + address('5Q544fKrFoe6tsEbD7SEmxGTJYAKtTVhAW5Q5pge4j1'), + address('675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'), + address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + address('D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6'), + ], + writable: [ + address('3ysyXtPPXnDiipsHovd6aaHdmuS62B3MTWgZBL5TF4Lc'), + address('7j7M3whuNZ2pNK8KWFJLVY5EWiF3P5HvxkUZb9LMiKxf'), + address('8ns4WyfWQCWkNAHDyRPnT6J4yqSKZL6GBWTWoXkwK4Jr'), + address('7kVpsksdgQ6cUaWNsw6xRK5a3S6PJtz9JKcp8k6N4HqG'), + address('8j2dXaFbkzoUaxVtuAadqpNEzQoPJcpuGQquWWaZFp6J'), + address('AkVaJ7h2B8Q5VE2Gq5Un5eiF1B8xTWi1QnvPuCTG9eAZ'), + address('EjU7eN3QmnT5YyXXaW7f7oMcv91yrsoT8EKiDk63NvHc'), + address('GqXYpBYyYa7eeRxSe59HaWtL8LadHxAj5rjUKiskjUht'), + address('H8Fug2JQVfRmkMtc4nhCSondkQN4tLy7TyrQ9wFDenvX'), + ], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: Route', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 invoke [2]', + 'Program log: ray_log: A4ACvesBAAAAAAAAAAAAAAACAAAAAAAAANqSgOMWAAAA0zl6354AAAA8uE7KlTAAAJxSMDeUAAAA', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 123909 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 116192 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 consumed 29658 of 140350 compute units', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 108962 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [2]', + 'Program log: Instruction: Swap', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6147 of 67780 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 58200 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [3]', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 2134 of 48531 compute units', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 58780 of 103609 compute units', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 43097 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 104005 of 145700 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 failed: custom program error: 0x1771', + ], + postBalances: [ + lamports(3255137060n), + lamports(2039280n), + lamports(98308174027n), + lamports(71437440n), + lamports(1n), + lamports(1n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(206124800n), + lamports(682356201411n), + lamports(2039280n), + lamports(455539991932n), + lamports(7182720n), + lamports(71437440n), + lamports(71437440n), + lamports(2039280n), + lamports(23385600n), + lamports(999714983180n), + lamports(934087680n), + lamports(22305427381n), + lamports(1141440n), + lamports(1141440n), + lamports(83000835n), + lamports(4000000n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('9az5xpAV8KJ2Q2Jb1ZvBpvfUa5Cj4dZirbgvfPF5XsB8'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('9az5xpAV8KJ2Q2Jb1ZvBpvfUa5Cj4dZirbgvfPF5XsB8'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '98306134746' as StringifiedBigInt, + decimals: 9, + uiAmount: 98.306134746, + uiAmountString: '98.306134746' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '682354162131' as StringifiedBigInt, + decimals: 9, + uiAmount: 682.354162131, + uiAmountString: '682.354162131' as StringifiedNumber, + }, + }, + { + accountIndex: 11, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '53419902416956' as StringifiedBigInt, + decimals: 6, + uiAmount: 53419902.416956, + uiAmountString: '53419902.416956' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('8j2dXaFbkzoUaxVtuAadqpNEzQoPJcpuGQquWWaZFp6J'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '455537952652' as StringifiedBigInt, + decimals: 9, + uiAmount: 455.537952652, + uiAmountString: '455.537952652' as StringifiedNumber, + }, + }, + { + accountIndex: 16, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('8j2dXaFbkzoUaxVtuAadqpNEzQoPJcpuGQquWWaZFp6J'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '39795030203226' as StringifiedBigInt, + decimals: 6, + uiAmount: 39795030.203226, + uiAmountString: '39795030.203226' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(3255142206n), + lamports(2039280n), + lamports(98308174027n), + lamports(71437440n), + lamports(1n), + lamports(1n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(206124800n), + lamports(682356201411n), + lamports(2039280n), + lamports(455539991932n), + lamports(7182720n), + lamports(71437440n), + lamports(71437440n), + lamports(2039280n), + lamports(23385600n), + lamports(999714983180n), + lamports(934087680n), + lamports(22305427381n), + lamports(1141440n), + lamports(1141440n), + lamports(83000835n), + lamports(4000000n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('9az5xpAV8KJ2Q2Jb1ZvBpvfUa5Cj4dZirbgvfPF5XsB8'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('9az5xpAV8KJ2Q2Jb1ZvBpvfUa5Cj4dZirbgvfPF5XsB8'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '98306134746' as StringifiedBigInt, + decimals: 9, + uiAmount: 98.306134746, + uiAmountString: '98.306134746' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '682354162131' as StringifiedBigInt, + decimals: 9, + uiAmount: 682.354162131, + uiAmountString: '682.354162131' as StringifiedNumber, + }, + }, + { + accountIndex: 11, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '53419902416956' as StringifiedBigInt, + decimals: 6, + uiAmount: 53419902.416956, + uiAmountString: '53419902.416956' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('8j2dXaFbkzoUaxVtuAadqpNEzQoPJcpuGQquWWaZFp6J'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '455537952652' as StringifiedBigInt, + decimals: 9, + uiAmount: 455.53792652, + uiAmountString: '455.53792652' as StringifiedNumber, + }, + }, + { + accountIndex: 16, + mint: address('9m3nh7YDoF1WSYpNxCjKVU8D1MrXsWRic4HqRaTdcTYB'), + owner: address('8j2dXaFbkzoUaxVtuAadqpNEzQoPJcpuGQquWWaZFp6J'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '39795030203226' as StringifiedBigInt, + decimals: 6, + uiAmount: 39795030.203226, + uiAmountString: '39795030.203226' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { Err: { InstructionError: [2, { Custom: 6001 }] } }, + }, + slot: 326686187n, + transaction: { + message: { + accountKeys: [ + address('9az5xpAV8KJ2Q2Jb1ZvBpvfUa5Cj4dZirbgvfPF5XsB8'), + address('5AZA8P8dN5VMvkBeqbP5rJx2pWBr5cUo4FRYXY8LMh5i'), + address('9fuGSiFvpkWQutKZAPW8TZDdyjHaCWyZpjGeeenxHz9c'), + address('E2bvANTdbG1dSbFuZNWbiHQrrQkntA8gX1GkBzn44HXv'), + address('11111111111111111111111111111111'), + address('ComputeBudget111111111111111111111111111111'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + ] as Address[], + addressTableLookups: [ + { + accountKey: address('9RqhCVZij68nidFH9UydNHvuz4t9unLKi6zvkeokcpiN'), + readableIndexes: [3, 2, 57, 54], + writableIndexes: [133, 130, 129], + }, + { + accountKey: address('4MxCjViJkeZE7TGk3MX5UQA8ti6NViLKnN17yRfmfVbN'), + readableIndexes: [145, 137, 141], + writableIndexes: [140, 139, 135, 142, 144, 136], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 5, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'G919uy' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + { + accounts: [], + data: '3tGNFMqHiozw' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + { + accounts: [ + 19, 0, 2, 2, 6, 18, 6, 8, 6, 21, 19, 9, 20, 9, 10, 11, 9, 9, 9, 9, + 9, 9, 9, 9, 2, 1, 0, 22, 13, 22, 16, 12, 1, 2, 23, 18, 17, 22, 0, + 19, 19, 24, 22, 3, 14, 15, 6, + ], + data: '3aafXU8vKpJ2E1MADMA3LWJLQVy2M6eNFTCXwReN36ZiGt3qHu4m1R' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'D2CkGEKruGkek11gFQqoRptnXWeTu4CF2h7V4A8PgW37', + ), + }, + signatures: [ + '58FymkjJUeSFGeEdaUQZbhHP5tdwwvbRR8BfKfuEgfYznqDqsApRBk8LCtiKny9EjQZBNi5NxGvLjR6F3gY6rxn1', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-obric.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-obric.ts new file mode 100644 index 00000000..4da72775 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-obric.ts @@ -0,0 +1,381 @@ +import { blockhash, lamports } from '@solana/kit'; +import type { + Address, + Base58EncodedBytes, + Lamports, + Slot, + StringifiedBigInt, + StringifiedNumber, +} from '@solana/kit'; +import type { UnixTimestamp } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +export const EXPECTED_SWAP_SOL_TO_OBRIC_DATA: SolanaTransaction = { + blockTime: 1748545222n as UnixTimestamp, + meta: { + computeUnitsConsumed: 101807n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(17129n), + innerInstructions: [ + { + index: 2, + instructions: [ + { + accounts: [18], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 2, + }, + { + accounts: [0, 2], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: 2, + }, + { + accounts: [2], + data: 'P' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 2, + }, + { + accounts: [2, 18], + data: '6Q6A46mVa7ntxYLTkQL1uwuXsmxKehnHCxSemuaNkzZYa' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 2, + }, + ], + }, + { + index: 3, + instructions: [ + { + accounts: [14, 9, 5, 12, 13, 2, 1, 15, 16, 19, 0, 11], + data: '2j6vnwYDURn8yxjpGDgSwCk2TC7zm3ds9vF' as Base58EncodedBytes, + programIdIndex: 17, + stackHeight: 2, + }, + { + accounts: [1, 13, 0], + data: '3GVQAnZaHe7Z' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [12, 2, 14], + data: '3YCx4dunCXKm' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [8], + data: 'QMqFu4fYGGeUEysFnenhAvD866YwW6jMndC6NeFLmgrgSsQrYzqQkLQZLriiyYAHU6DY9CABySGbF8TQvwB3my7Y4x2mCV92TazD1F3CPHC5Lc1t9Sxvsw4uAWevnAMmvZuNDYJ2mVPStyXdQdpeMWPunS72XGr9kntRo29mYiYQRSF' as Base58EncodedBytes, + programIdIndex: 10, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + 'J4HJYz4p7TRP96WVFky3vh7XryxoFehHjoRySUTeSeXw' as Address, + 'obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y' as Address, + 'So11111111111111111111111111111111111111112' as Address, + 'Sysvar1nstructions1111111111111111111111111' as Address, + ], + writable: [ + '86KSdCfcqnJo9TCLFi3zxsJAJzvx9QU7oEPd6Fn5ZPom' as Address, + '8ofECjHnVGLU4ywyPdK6mFddEqAuXsnrrov8m2zeFhvj' as Address, + 'Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK' as Address, + 'FpCMFDFGYotvufJ7HrFHsWEiiQCGbkLCtwHiDnh7o28Q' as Address, + ], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 115585 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 108998 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 105116 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 19315 of 120990 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: Route', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y invoke [2]', + 'Program log: Instruction: Swap', + 'Program log: price_x: 1679383', + 'Program log: price_y: 10000', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 49483 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 41962 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program log: YX15 224719444972,123013945942,991250,5903984', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y consumed 66637 of 97183 compute units', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 28810 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 74633 of 101675 compute units', + 'Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 cBZaAAAAAAA=', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 27042 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 24127 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + 1538468067n as Lamports, + 2039280n as Lamports, + 0n as Lamports, + 2039280n as Lamports, + 1n as Lamports, + 29900160n as Lamports, + 731913600n as Lamports, + 1n as Lamports, + 1017968n as Lamports, + 8741760n as Lamports, + 1141440n as Lamports, + 934087680n as Lamports, + 224715580269n as Lamports, + 2039280n as Lamports, + 5526241n as Lamports, + 3167032033n as Lamports, + 2561280n as Lamports, + 1141440n as Lamports, + 1045539216193n as Lamports, + 0n as Lamports, + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '11827324' as StringifiedBigInt, + decimals: 6, + uiAmount: 11.827324, + uiAmountString: '11.827324' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '244533398' as StringifiedBigInt, + decimals: 6, + uiAmount: 244.533398, + uiAmountString: '244.533398' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: 'So11111111111111111111111111111111111111112' as Address, + owner: 'Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '224713540988' as StringifiedBigInt, + decimals: 9, + uiAmount: 224.713540988, + uiAmountString: '224.713540988' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: 'Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '123014937192' as StringifiedBigInt, + decimals: 6, + uiAmount: 123014.937192, + uiAmountString: '123014.937192' as StringifiedNumber, + }, + }, + ], + preBalances: [ + 1532581212n as Lamports, + 2039280n as Lamports, + 0n as Lamports, + 2039280n as Lamports, + 1n as Lamports, + 29900160n as Lamports, + 731913600n as Lamports, + 1n as Lamports, + 1017968n as Lamports, + 8741760n as Lamports, + 1141440n as Lamports, + 934087680n as Lamports, + 224721484253n as Lamports, + 2039280n as Lamports, + 5526241n as Lamports, + 3167032033n as Lamports, + 2561280n as Lamports, + 1141440n as Lamports, + 1045539216193n as Lamports, + 0n as Lamports, + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '12827324' as StringifiedBigInt, + decimals: 6, + uiAmount: 12.827324, + uiAmountString: '12.827324' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '244524648' as StringifiedBigInt, + decimals: 6, + uiAmount: 244.524648, + uiAmountString: '244.524648' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: 'So11111111111111111111111111111111111111112' as Address, + owner: 'Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '224719444972' as StringifiedBigInt, + decimals: 9, + uiAmount: 224.719444972, + uiAmountString: '224.719444972' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: 'Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '123013945942' as StringifiedBigInt, + decimals: 6, + uiAmount: 123013.945942, + uiAmountString: '123013.945942' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 343302515n, + transaction: { + message: { + accountKeys: [ + '3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c' as Address, + 'F77xG4vz2CJeMxxAmFW8pvPx2c5Uk75pksr6Wwx6HFhV' as Address, + 'Ffqao4nxSvgaR5kvFz1F718WaxSv6LnNfHuGqFEZ8fzL' as Address, + 'H4FVf2mGfHN26D1CkZ6sJAb6xUhhnW1w9abpaxHnUbUD' as Address, + '11111111111111111111111111111111' as Address, + '6YawcNeZ74tRyCv4UfGydYMr7eho7vbUR6ScVffxKAb3' as Address, + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' as Address, + 'ComputeBudget111111111111111111111111111111' as Address, + 'D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf' as Address, + 'GZsNmWKbqhMYtdSkkvMdEyQF9k5mLmP7tTKYWZjcHVPE' as Address, + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' as Address, + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + ], + addressTableLookups: [ + { + accountKey: 'HPLCVFcCMgt6mp5uZLo9u8WnqAe2Yan7Sf285fRmitYP' as Address, + readableIndexes: [14, 15, 42, 16], + writableIndexes: [18, 13, 12, 11], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 8, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3gJqkocMWaMm' as Base58EncodedBytes, + programIdIndex: 7, + stackHeight: null, + }, + { + accounts: [], + data: 'KGo3nf' as Base58EncodedBytes, + programIdIndex: 7, + stackHeight: null, + }, + { + accounts: [0, 2, 0, 18, 4, 11], + data: '2' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [ + 11, 0, 1, 2, 10, 18, 10, 8, 10, 17, 14, 9, 5, 12, 13, 2, 1, 15, 16, + 19, 0, 11, + ], + data: '2jtsaD446yyqqK5qJ4cgMnD16xhu3vMUQSmXDEWJrsxn4RTnsh' as Base58EncodedBytes, + programIdIndex: 10, + stackHeight: null, + }, + { + accounts: [2, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: null, + }, + { + accounts: [1, 3, 0], + data: '3MB7Gffrb7zX' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'FN4BriKgvHGgyzrz1iZ1rv2zfAvogZ9fFbKiwL8b9Eru', + ), + }, + signatures: [ + '28rWme56aMyaP8oX18unFeZg65iyDEhjLhvMBpxyFgKcn38P37ZRsssSZoHDCCr5xUfwfpqsVSSBoShLitHQLdrr' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-sahur.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-sahur.ts new file mode 100644 index 00000000..44d07392 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-sahur.ts @@ -0,0 +1,381 @@ +import { address, blockhash, lamports } from '@solana/kit'; +import type { + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, +} from '@solana/kit'; +import type { UnixTimestamp } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +export const EXPECTED_SWAP_SOL_TO_SAHUR_DATA: SolanaTransaction = { + blockTime: 1747062836n as UnixTimestamp, + meta: { + computeUnitsConsumed: 61675n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(11785n), + innerInstructions: [ + { + index: 4, + instructions: [ + { + accounts: [20], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [0, 8], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 14, + stackHeight: 2, + }, + { + accounts: [8], + data: 'P' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [8, 20], + data: '6bY9pNeU2qwkJgSyHZ9tDZHS8MKeAVk2hkGBGD7vwkPE4' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + ], + }, + { + index: 5, + instructions: [ + { + accounts: [7, 6, 0], + data: '3ay2hEw4e3yH' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [5, 8, 15], + data: '3FKYuLupPLfR' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [], + writable: [], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: InitializeAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3443 of 67392 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: Create', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 55582 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 48995 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4188 of 45113 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 23307 of 63949 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 invoke [1]', + 'Program log: ray_log: A4CWmAAAAAAAkk3n6wAAAAACAAAAAAAAAICWmAAAAAAA1c0H9E0AAADpu0A6PXsAAAshn/AAAAAA', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 22522 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 14805 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 consumed 31560 of 40642 compute units', + 'Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 9082 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(1966049981n), + lamports(3591360n), + lamports(16258560n), + lamports(79594560n), + lamports(2039280n), + lamports(2039280n), + lamports(334818673221n), + lamports(0n), + lamports(2039280n), + lamports(6124800n), + lamports(101977920n), + lamports(2039280n), + lamports(23357760n), + lamports(101977920n), + lamports(1n), + lamports(24648491902n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(1n), + lamports(1461600n), + lamports(1041216875087n), + lamports(1141440n), + lamports(1009200n), + lamports(934087680n), + ], + postTokenBalances: [ + { + accountIndex: 4, + mint: address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + owner: address('B9mWNVKLNcWLGZ1KLWDPL7zgAqDLdYh35DSkBFQuJYqX'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '135498863581918' as StringifiedBigInt, + decimals: 6, + uiAmount: 135498863.581918, + uiAmountString: '135498863.581918' as StringifiedNumber, + }, + }, + { + accountIndex: 6, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '334816633941' as StringifiedBigInt, + decimals: 9, + uiAmount: 334.816633941, + uiAmountString: '334.816633941' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + owner: address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4036960523' as StringifiedBigInt, + decimals: 6, + uiAmount: 4036.960523, + uiAmountString: '4036.960523' as StringifiedNumber, + }, + }, + { + accountIndex: 11, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('B9mWNVKLNcWLGZ1KLWDPL7zgAqDLdYh35DSkBFQuJYqX'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 9, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(1978101046n), + lamports(3591360n), + lamports(16258560n), + lamports(79594560n), + lamports(2039280n), + lamports(2039280n), + lamports(334808673221n), + lamports(0n), + lamports(0n), + lamports(6124800n), + lamports(101977920n), + lamports(2039280n), + lamports(23357760n), + lamports(101977920n), + lamports(1n), + lamports(24648491902n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(1n), + lamports(1461600n), + lamports(1041216875087n), + lamports(1141440n), + lamports(1009200n), + lamports(934087680n), + ], + preTokenBalances: [ + { + accountIndex: 4, + mint: address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + owner: address('B9mWNVKLNcWLGZ1KLWDPL7zgAqDLdYh35DSkBFQuJYqX'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 6, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '135502900542441' as StringifiedBigInt, + decimals: 6, + uiAmount: 135502900.542441, + uiAmountString: '135502900.542441' as StringifiedNumber, + }, + }, + { + accountIndex: 6, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '334806633941' as StringifiedBigInt, + decimals: 9, + uiAmount: 334.806633941, + uiAmountString: '334.806633941' as StringifiedNumber, + }, + }, + { + accountIndex: 11, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('B9mWNVKLNcWLGZ1KLWDPL7zgAqDLdYh35DSkBFQuJYqX'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 9, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 339549448n, + transaction: { + message: { + accountKeys: [ + address('FQT9SSwEZ6UUQxsmTzgt5JzjrS4M5zm13M1QiYF8TEo6'), + address('27oB3gVTet3fCTcNtritpNRpaWKxzxCdQV2AjNX1dHX8'), + address('4PXA5cidijHQLh1xCSxQwnJqummAvq1SYtKcp8BTGsve'), + address('5JWKB5pucKsor4GRTDpX9xDmUqdLpkQyCTEWyrZsZ6UK'), + address('9ufdHECN1fA4vXegjKbNkCdAHMAsYq24fjnK4JTftrDW'), + address('Aafu2k5FVqxV2w5xhcsDD7PNDDGr1ZqvvkocHGSCfmnZ'), + address('AjYa7GYvzN8asK8wEfeSTqn68N5RuA3wTaCwwU7BiZLt'), + address('AnamvVx5NDygfsxY9fiqfcxTnF6pUb5gXWqtfgfj8ReF'), + address('ASWT19iqRNdDdThgq6jMTcXPkVogeBhrLq1Q1tQai2yM'), + address('EXfpndEFLDYdvkhS7cvuGd6yzzbQcFiFE5EUsVQkb4Nt'), + address('FNqd4KaiNiKDGw2RvkpJmHZ7Z4U6UBagNZrTUeEDLD2h'), + address('GmLJpXYdCUCo93JYJPmXDgwQ1WRu82DrRyb3ZMCy7uXD'), + address('HLithjbyqCTFb8jMoP5w9M3MktMFzJXCmX7Q1wrsEUAA'), + address('HZEga2jRMiDZgQHp9i5K6vPMRFuLGa2F4FNkiLSDcon7'), + address('11111111111111111111111111111111'), + address('5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'), + address('675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('B9mWNVKLNcWLGZ1KLWDPL7zgAqDLdYh35DSkBFQuJYqX'), + address('ComputeBudget111111111111111111111111111111'), + address('DNrmDMs2czDaAwgzg2BmvM7Jn5ZqA6VN5huRqCrSpump'), + address('So11111111111111111111111111111111111111112'), + address('srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX'), + address('SysvarRent111111111111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 11, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'E95fG3' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: null, + }, + { + accounts: [], + data: '3gJqkocMWaMm' as Base58EncodedBytes, + programIdIndex: 19, + stackHeight: null, + }, + { + accounts: [0, 7], + data: '3ipZX4AeNfzKsbSQkSLaYC6Ziq2LJBz78KzQ4mQyfjmQ6ZG5sTLit1mp8ZYa6dFKH1ERhnbauk2KyK7nQ5vQ51ezgFJBeZmQESajubRDuTnTxSVk4qYRaA6SqKma1yETYz9PM1wVzgq4y7TsjCCytCXXfwv8UR2eNhvgXXP76' as Base58EncodedBytes, + programIdIndex: 14, + stackHeight: null, + }, + { + accounts: [7, 21, 0, 23], + data: '2' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: null, + }, + { + accounts: [0, 8, 0, 20, 14, 24], + data: '' as Base58EncodedBytes, + programIdIndex: 17, + stackHeight: null, + }, + { + accounts: [ + 24, 9, 15, 12, 2, 6, 5, 22, 1, 10, 13, 3, 11, 4, 18, 7, 8, 0, + ], + data: '6BPQkA7DGVXuvSWePxQX7Ry' as Base58EncodedBytes, + programIdIndex: 16, + stackHeight: null, + }, + { + accounts: [7, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'E5dsowT7fWPRCQmxtKu4Eucb7CnwQQgMtHqjfdfcaVtb', + ), + }, + signatures: [ + 'QfFVu1P4ji8EFTor4BcRYS84wipv2RJDhyzi3w3YQgv1D8mxz2RhF9cKxi4k6DZdtzDRPL6a346HboDUAsSUCfV' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-usdc.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-usdc.ts new file mode 100644 index 00000000..7c454592 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-usdc.ts @@ -0,0 +1,325 @@ +import { address, blockhash, lamports } from '@solana/kit'; +import type { + Base58EncodedBytes, + Slot, + StringifiedBigInt, + StringifiedNumber, +} from '@solana/kit'; +import type { UnixTimestamp } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +export const EXPECTED_SWAP_SOL_TO_USDC_DATA: SolanaTransaction = { + blockTime: 1748539118n as UnixTimestamp, + meta: { + computeUnitsConsumed: 84264n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(15072n), + innerInstructions: [ + { + index: 4, + instructions: [ + { + accounts: [14, 9, 5, 12, 13, 3, 2, 15, 16, 18, 0, 11], + data: '2j6vnwYDURn8zWMJwBRrXJ3TT2s5WHfkXGB' as Base58EncodedBytes, + programIdIndex: 17, + stackHeight: 2, + }, + { + accounts: [3, 12, 0], + data: '3GVQAnZaHe7Z' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [13, 2, 14], + data: '3P5ssESYZrQP' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [7], + data: 'QMqFu4fYGGeUEysFnenhAvD866YwW6jMndC6NeFLmgrgSsQrYzqQkLQZLriiyYAHU47xKZ9Dcp6oHcAMxjdC9NFJecmNYi1Ua1ZLQMxJVTMdjBqjMUV4B4Y6vSt7RBTsrHYS4DiadmRniBeDrctYwP69iEKHPv7FfddnmAAhnJjGYHm' as Base58EncodedBytes, + programIdIndex: 10, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('J4HJYz4p7TRP96WVFky3vh7XryxoFehHjoRySUTeSeXw'), + address('obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y'), + address('Sysvar1nstructions1111111111111111111111111'), + ], + writable: [ + address('86KSdCfcqnJo9TCLFi3zxsJAJzvx9QU7oEPd6Fn5ZPom'), + address('8ofECjHnVGLU4ywyPdK6mFddEqAuXsnrrov8m2zeFhvj'), + address('Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK'), + address('FpCMFDFGYotvufJ7HrFHsWEiiQCGbkLCtwHiDnh7o28Q'), + ], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: SyncNative', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3045 of 100269 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: Route', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y invoke [2]', + 'Program log: Instruction: Swap', + 'Program log: price_x: 1687083', + 'Program log: price_y: 10000', + 'Program log: 22081301166132000000000000000, 1687083, 10000000', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 41937 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 34325 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program log: XY15 198804949968,127372922907,991250,167225', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y consumed 69706 of 92730 compute units', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 21288 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 77704 of 97224 compute units', + 'Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 OY0CAAAAAAA=', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 19520 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success', + ], + postBalances: [ + lamports(1533615663n), + lamports(7648885090n), + lamports(2039280n), + lamports(0n), + lamports(1n), + lamports(29900160n), + lamports(1n), + lamports(1017968n), + lamports(391278827123n), + lamports(8741760n), + lamports(1141440n), + lamports(934087680n), + lamports(198807980499n), + lamports(2039280n), + lamports(5526241n), + lamports(3167032033n), + lamports(2561280n), + lamports(1141440n), + lamports(0n), + ], + postTokenBalances: [ + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '12660400' as StringifiedBigInt, + decimals: 6, + uiAmount: 12.6604, + uiAmountString: '12.6604' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '198805941218' as StringifiedBigInt, + decimals: 9, + uiAmount: 198.805941218, + uiAmountString: '198.805941218' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '127372755682' as StringifiedBigInt, + decimals: 6, + uiAmount: 127372.755682, + uiAmountString: '127372.755682' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(1532591455n), + lamports(7648876340n), + lamports(2039280n), + lamports(2039280n), + lamports(1n), + lamports(29900160n), + lamports(1n), + lamports(1017968n), + lamports(391278827123n), + lamports(8741760n), + lamports(1141440n), + lamports(934087680n), + lamports(198806989249n), + lamports(2039280n), + lamports(5526241n), + lamports(3167032033n), + lamports(2561280n), + lamports(1141440n), + lamports(0n), + ], + preTokenBalances: [ + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '12493175' as StringifiedBigInt, + decimals: 6, + uiAmount: 12.493175, + uiAmountString: '12.493175' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '0' as StringifiedBigInt, + decimals: 9, + uiAmount: null, + uiAmountString: '0' as StringifiedNumber, + }, + }, + { + accountIndex: 12, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '198804949968' as StringifiedBigInt, + decimals: 9, + uiAmount: 198.804949968, + uiAmountString: '198.804949968' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('Fn68NZzCCgZKtYmnAYbkL6w5NNx3TgjW91dGkLA3hsDK'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '127372922907' as StringifiedBigInt, + decimals: 6, + uiAmount: 127372.922907, + uiAmountString: '127372.922907' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 343286989n, + transaction: { + message: { + accountKeys: [ + address('3xTPAZxmpwd8GrNEKApaTw6VH4jqJ31WFXUvQzgwhR7c'), + address('4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA'), + address('F77xG4vz2CJeMxxAmFW8pvPx2c5Uk75pksr6Wwx6HFhV'), + address('Ffqao4nxSvgaR5kvFz1F718WaxSv6LnNfHuGqFEZ8fzL'), + address('11111111111111111111111111111111'), + address('6YawcNeZ74tRyCv4UfGydYMr7eho7vbUR6ScVffxKAb3'), + address('ComputeBudget111111111111111111111111111111'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + address('GZsNmWKbqhMYtdSkkvMdEyQF9k5mLmP7tTKYWZjcHVPE'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [ + { + accountKey: address('HPLCVFcCMgt6mp5uZLo9u8WnqAe2Yan7Sf285fRmitYP'), + readableIndexes: [14, 15, 16], + writableIndexes: [18, 13, 12, 11], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 8, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: '3gJqkocMWaMm' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [], + data: 'GwRJco' as Base58EncodedBytes, + programIdIndex: 6, + stackHeight: null, + }, + { + accounts: [0, 3], + data: '3Bxs43tVpaBHi3Us' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [3], + data: 'J' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: null, + }, + { + accounts: [ + 11, 0, 3, 2, 10, 8, 10, 7, 10, 17, 14, 9, 5, 12, 13, 3, 2, 15, 16, + 18, 0, 11, + ], + data: '2jtsaD446yyqqK5qJ4chZG7pXXNU4VZALP6XLdRUcyHgJXcopj' as Base58EncodedBytes, + programIdIndex: 10, + stackHeight: null, + }, + { + accounts: [3, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: null, + }, + { + accounts: [0, 1], + data: '3Bxs48aCvTHa1XMq' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'Gs6cqGFa4N87fqQoib8BVPaHHJxmS4ynF9yXcusaqAmK', + ), + }, + signatures: [ + '2X4vKsDS56avumw6jh6Z5wj28GNvK5UkMfY1Ta2Mtouic886EGAraa1gsCJ5rkXyHCeL9p2wHRty3QHAhjf352Dp' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-trump-to-jup.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-trump-to-jup.ts new file mode 100644 index 00000000..f5bd9c64 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-trump-to-jup.ts @@ -0,0 +1,837 @@ +import type { + Base58EncodedBytes, + Base64EncodedDataResponse, + Slot, + TokenBalance, + TransactionError, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +// This structure is based on the getTransaction RPC response, +// so the SolanaTransaction is nested under the "result" property. +export const RPC_RESPONSE_SWAP_TRUMP_TO_JUP_DATA = { + jsonrpc: '2.0', + result: { + blockTime: 1746657467n as UnixTimestamp, + meta: { + computeUnitsConsumed: 310639n, + // eslint-disable-next-line id-denylist + err: null as TransactionError | null, + fee: lamports(41876n), + innerInstructions: [ + { + index: 7, + instructions: [ + { + accounts: [38], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 2, + }, + { + accounts: [0, 2], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 9, + stackHeight: 2, + }, + { + accounts: [2], + data: 'P' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 2, + }, + { + accounts: [2, 38], + data: '6UcuLLpKgADaq3hvjtNRFUmm68rGNvhDLndvTpArJooK3' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 2, + }, + ], + }, + { + index: 8, + instructions: [ + { + accounts: [4, 8, 0], + data: '3fWa4b3Pi7YT' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 2, + }, + { + accounts: [18, 30, 32, 31, 8, 6, 23, 43], + data: 'KJbLmHu6nV7UqvDDcSRXaFUb' as Base58EncodedBytes, + programIdIndex: 40, + stackHeight: 2, + }, + { + accounts: [8, 32, 18], + data: '3fWa4b3Pi7YT' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [31, 6, 30], + data: '3GX2cxozGc1m' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [17], + data: 'QMqFu4fYGGeUEysFnenhAvC84LqwVcxGQ4jSwmA9PGzJR1g2ER75NWZc21f6bKJsrmwYHcfVa7ixjCYtwp3uNHFMEaUUsNuibdeoCSCzWBybS4ZgW8UYtotFR7fBXnrGmADuUF2gespU9CTGYKo5FwDk2pgVJWD7rmEnQuZYt3jP4go' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + { + accounts: [34, 19, 14, 36, 33, 7, 6, 35, 41, 43, 18, 23], + data: '2j6vnwYDURn8yxm1zZVZQzwPQGTX2RdjMKD' as Base58EncodedBytes, + programIdIndex: 42, + stackHeight: 2, + }, + { + accounts: [6, 33, 18], + data: '3GX2cxozGc1m' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [36, 7, 34], + data: '3Deg5NNBD3yh' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [17], + data: 'QMqFu4fYGGeUEysFnenhAvD866YwW6jMndC6NeFLmgrgSsQrYzqQkLQZLriiyYAHU6DY9CABySGbF8TQvwB3my7Y4x2mCV92TazD1F3CPHC5Lc2ErsMARmjtoTmKngZ6UR831Z5VpwjEVpgQy1kMxEdSemovvUGKpUJtf81j86nnjwd' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + { + accounts: [23, 18, 29, 5, 24, 7, 25, 28, 26, 27, 37], + data: '59p8WydnSZtRqYRRqT2PMbAzHZ5kroPaPguDn2DUNBt9mMjmgrrD9jkNAj' as Base58EncodedBytes, + programIdIndex: 39, + stackHeight: 2, + }, + { + accounts: [7, 25, 18], + data: '3Deg5NNBD3yh' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [24, 5, 29], + data: '3KhWHdLNoEX1' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 3, + }, + { + accounts: [17], + data: 'QMqFu4fYGGeUEysFnenhAvDWgqp1W7DbrMv3z8JcyrP4Bu3Yyyj7irLW76wEzMiFqiFwoETYwdqiPRSaEKSWpjDuenVF1jJfDrxNf9W2BiSt1cTq4rGGKEGDnUHBaeG8JVk6LXS6FpT2cMo8F66NJwvUCzpmd2rzL59NPvihcNdZ9ts' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + { + accounts: [5, 2, 18], + data: '3KhWHdLNoEX1' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('65fuZoZgftu7midti6tErqGriTQP7qCng7MbFcMCN8LA'), + address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + address('whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc'), + address('SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe'), + address('J4HJYz4p7TRP96WVFky3vh7XryxoFehHjoRySUTeSeXw'), + address('obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y'), + address('Sysvar1nstructions1111111111111111111111111'), + ], + writable: [ + address('2wSq4BswujbEARh57hC227PkNo9eMyGCv8mYV9SN7myj'), + address('7fibbSXkUuc4HqPWnyszmtgwUnPzZJUQFBhZJTHUS5cL'), + address('9d8PVkwKTspxXJBweAM9VNpaMMa5ApvvtK7PQtQrryca'), + address('By7iRuzUgZriSBgezQYMtxi2tVpZd26UMevuvX48Vb7t'), + address('Cb8LJ6KxA4TAB7qBpZxkBuReiimx2XiCMn6YqTuw6YK3'), + address('FgTCR1ufcaTZMwZZYhNRhJm2K3HgMA8V8kXtdqyttm19'), + address('3AbG3ZA19fJKjTSTMTCz7j2bodPagXog4PwTBi8H7UA4'), + address('AHRTN52eBDEjMgJuLaTBUU6MkT5i9i6KMdGop8Fi7hkG'), + address('HrYQMvm9ZSmnuQL1QKBqW3rzghikpVFgfD1ZHYw22JLo'), + address('74tjvZXuW2C7bsBxRYxmwTrqF8BrYr3VDgusj8DwRd9a'), + address('AvBSC1KmFNceHpD6jyyXBV6gMXFxZ8BJJ3HVUN8kCurJ'), + address('FpCMFDFGYotvufJ7HrFHsWEiiQCGbkLCtwHiDnh7o28Q'), + address('FpEzVYQ5MjuSut61Ka18tzYhQKLqndefubV7K2U1mrTz'), + ], + }, + logMessages: [ + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br invoke [1]', + 'Program log: LI.FI TX: 0xB11205FAE4747F00', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br consumed 6037 of 368756 compute units', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 5937 of 362419 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6241 of 356482 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 4437 of 350241 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6146 of 345804 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 334253 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 327666 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4188 of 323784 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20345 of 339658 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: SharedAccountsRoute', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 315158 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe invoke [2]', + 'Program log: @@@:ICxWnWBKw+B8KqChdqtmFUFTafZWPRUSqTgDcc+vKWnXUB8BAAAAAHhvLRQAAAAAAAAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 209660 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 202875 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe consumed 109667 of 307657 compute units', + 'Program SoLFiHG9TfgtdUXUjWAxi3LtvYuFyDLVhBWxdMZxyCe success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 196254 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y invoke [2]', + 'Program log: Instruction: Swap', + 'Program log: price_x: 1477185', + 'Program log: price_y: 10000', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 141130 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 133609 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program log: YX15 205222432421,69134667029,543506,3679489', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y consumed 70812 of 193016 compute units', + 'Program obriQD1zbpyLz95G5n7nJe6a4DPjpFwa5XYPoNm113y success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 120468 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [2]', + 'Program log: Instruction: Swap', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4736 of 83707 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 75911 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program data: 4cpJr5MroJbaHuOrSytMOWqa+q1Ljf62fmW+H6dODm4UjEISQuDWYABTuhAskupRsgEAAAAAAAAAmakUDDcNXLIBAAAAAAAAAAElOAAAAAAAJU8TAAAAAAAAAAAAAAAAAAAAAAAAAAAADH0AAAAAAACvEgAAAAAAAA==', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc consumed 49364 of 117109 compute units', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 66008 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 62894 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 261196 of 319313 compute units', + 'Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 JU8TAAAAAAA=', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + ], + postBalances: [ + lamports(52309328n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(2039980n), + lamports(278226640n), + lamports(2039280n), + lamports(1n), + lamports(26304001609n), + lamports(1141440n), + lamports(114032932n), + lamports(1345757052440n), + lamports(29900160n), + lamports(731913600n), + lamports(1n), + lamports(1017918n), + lamports(2103217742n), + lamports(8741760n), + lamports(0n), + lamports(1141440n), + lamports(1169280n), + lamports(934087680n), + lamports(2039280n), + lamports(81566957884n), + lamports(70407360n), + lamports(70407360n), + lamports(70407360n), + lamports(5435791n), + lamports(22426783n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(5570241n), + lamports(3167032033n), + lamports(205220792213n), + lamports(0n), + lamports(71103633970n), + lamports(1141440n), + lamports(1141440n), + lamports(2561280n), + lamports(1141440n), + lamports(0n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('34FKjAdVcTax2DHqV2XnbXa9J3zmyKcFuFKWbcmgxjgm'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '6223747', + decimals: 6, + uiAmount: 6.223747, + uiAmountString: '6.223747', + }, + }, + { + accountIndex: 2, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1265445', + decimals: 6, + uiAmount: 1.265445, + uiAmountString: '1.265445', + }, + }, + { + accountIndex: 3, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '30506', + decimals: 6, + uiAmount: 0.030506, + uiAmountString: '0.030506', + }, + }, + { + accountIndex: 4, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '83590', + decimals: 6, + uiAmount: 0.08359, + uiAmountString: '0.08359', + }, + }, + { + accountIndex: 5, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '26482927', + decimals: 6, + uiAmount: 26.482927, + uiAmountString: '26.482927', + }, + }, + { + accountIndex: 6, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '44965741', + decimals: 6, + uiAmount: 44.965741, + uiAmountString: '44.965741', + }, + }, + { + accountIndex: 7, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '276186969', + decimals: 9, + uiAmount: 0.276186969, + uiAmountString: '0.276186969', + }, + }, + { + accountIndex: 8, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '3169038', + decimals: 6, + uiAmount: 3.169038, + uiAmountString: '3.169038', + }, + }, + { + accountIndex: 24, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('FgTCR1ufcaTZMwZZYhNRhJm2K3HgMA8V8kXtdqyttm19'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '147713797837', + decimals: 6, + uiAmount: 147713.797837, + uiAmountString: '147713.797837', + }, + }, + { + accountIndex: 25, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('FgTCR1ufcaTZMwZZYhNRhJm2K3HgMA8V8kXtdqyttm19'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '81564918604', + decimals: 9, + uiAmount: 81.564918604, + uiAmountString: '81.564918604', + }, + }, + { + accountIndex: 31, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3AbG3ZA19fJKjTSTMTCz7j2bodPagXog4PwTBi8H7UA4'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '248776432133', + decimals: 6, + uiAmount: 248776.432133, + uiAmountString: '248776.432133', + }, + }, + { + accountIndex: 32, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('3AbG3ZA19fJKjTSTMTCz7j2bodPagXog4PwTBi8H7UA4'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '24228330103', + decimals: 6, + uiAmount: 24228.330103, + uiAmountString: '24228.330103', + }, + }, + { + accountIndex: 33, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('AvBSC1KmFNceHpD6jyyXBV6gMXFxZ8BJJ3HVUN8kCurJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '69135210535', + decimals: 6, + uiAmount: 69135.210535, + uiAmountString: '69135.210535', + }, + }, + { + accountIndex: 36, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('AvBSC1KmFNceHpD6jyyXBV6gMXFxZ8BJJ3HVUN8kCurJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '205218752932', + decimals: 9, + uiAmount: 205.218752932, + uiAmountString: '205.218752932', + }, + }, + ] as TokenBalance[], + preBalances: [ + lamports(54390484n), + lamports(2039280n), + lamports(0n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(2039980n), + lamports(278226640n), + lamports(2039280n), + lamports(1n), + lamports(26304001609n), + lamports(1141440n), + lamports(114032932n), + lamports(1345757052440n), + lamports(29900160n), + lamports(731913600n), + lamports(1n), + lamports(1017918n), + lamports(2103217742n), + lamports(8741760n), + lamports(0n), + lamports(1141440n), + lamports(1169280n), + lamports(934087680n), + lamports(2039280n), + lamports(81563278395n), + lamports(70407360n), + lamports(70407360n), + lamports(70407360n), + lamports(5435791n), + lamports(22426783n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(5570241n), + lamports(3167032033n), + lamports(205224471702n), + lamports(0n), + lamports(71103633970n), + lamports(1141440n), + lamports(1141440n), + lamports(2561280n), + lamports(1141440n), + lamports(0n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('34FKjAdVcTax2DHqV2XnbXa9J3zmyKcFuFKWbcmgxjgm'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '6223747', + decimals: 6, + uiAmount: 6.223747, + uiAmountString: '6.223747', + }, + }, + { + accountIndex: 3, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '30069', + decimals: 6, + uiAmount: 0.030069, + uiAmountString: '0.030069', + }, + }, + { + accountIndex: 4, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '133590', + decimals: 6, + uiAmount: 0.13359, + uiAmountString: '0.13359', + }, + }, + { + accountIndex: 5, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '26482927', + decimals: 6, + uiAmount: 26.482927, + uiAmountString: '26.482927', + }, + }, + { + accountIndex: 6, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '44965741', + decimals: 6, + uiAmount: 44.965741, + uiAmountString: '44.965741', + }, + }, + { + accountIndex: 7, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '276186969', + decimals: 9, + uiAmount: 0.276186969, + uiAmountString: '0.276186969', + }, + }, + { + accountIndex: 8, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '3169038', + decimals: 6, + uiAmount: 3.169038, + uiAmountString: '3.169038', + }, + }, + { + accountIndex: 24, + mint: address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + owner: address('FgTCR1ufcaTZMwZZYhNRhJm2K3HgMA8V8kXtdqyttm19'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '147715063282', + decimals: 6, + uiAmount: 147715.063282, + uiAmountString: '147715.063282', + }, + }, + { + accountIndex: 25, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('FgTCR1ufcaTZMwZZYhNRhJm2K3HgMA8V8kXtdqyttm19'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '81561239115', + decimals: 9, + uiAmount: 81.561239115, + uiAmountString: '81.561239115', + }, + }, + { + accountIndex: 31, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3AbG3ZA19fJKjTSTMTCz7j2bodPagXog4PwTBi8H7UA4'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '248776975639', + decimals: 6, + uiAmount: 248776.975639, + uiAmountString: '248776.975639', + }, + }, + { + accountIndex: 32, + mint: address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + owner: address('3AbG3ZA19fJKjTSTMTCz7j2bodPagXog4PwTBi8H7UA4'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '24228280540', + decimals: 6, + uiAmount: 24228.28054, + uiAmountString: '24228.28054', + }, + }, + { + accountIndex: 33, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('AvBSC1KmFNceHpD6jyyXBV6gMXFxZ8BJJ3HVUN8kCurJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '69134667029', + decimals: 6, + uiAmount: 69134.667029, + uiAmountString: '69134.667029', + }, + }, + { + accountIndex: 36, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('AvBSC1KmFNceHpD6jyyXBV6gMXFxZ8BJJ3HVUN8kCurJ'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '205222432421', + decimals: 9, + uiAmount: 205.222432421, + uiAmountString: '205.222432421', + }, + }, + ] as TokenBalance[], + returnData: { + data: [ + 'JU8TAAAAAAA=', // This is Base58, but the type expects Base64EncodedDataResponse + 'base64', + ] as Base64EncodedDataResponse, // Explicit cast, assuming the first element is actually base64 in this context + programId: address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + }, + rewards: [], + status: { + Ok: null, + }, + }, + slot: 338521977n, + transaction: { + message: { + accountKeys: [ + address('8VCfQcnssNJznDqDoDDuzoKhdxgZWwwe5ikcKbAVWet5'), + address('4Hf3wte9KU2y23sX4KQu6bzKXw25A6KyA8a262xP5B8c'), + address('57umWGXvPYbUT6SteYh4j7nz7FmgfRsveY1KhfJMHtxL'), + address('5GLUj9QA3ZHehzas4Wr8GbyK4EKjHFhAmz5aFrvVLmTv'), + address('DKvDup2As97yqRn4YBh8gWsUgShoWD1Vm8mT65teeLLL'), + address('DqHAep7hD2VJ4rsWSxkLoBSRMiLkdw7y8DLx1htrryts'), + address('DVCeozFGbe6ew3eWTnZByjHeYqTq1cvbrB7JJhkLxaRJ'), + address('g7dD1FHSemkUQrX1Eak37wzvDjscgBW2pFCENwjLdMX'), + address('K8E3xRZtDZQck2vFLUN2AqQUwMS8n9LUB3iNy26M5Au'), + address('11111111111111111111111111111111'), + address('34FKjAdVcTax2DHqV2XnbXa9J3zmyKcFuFKWbcmgxjgm'), + address('3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br'), + address('4cLUBQKZgCv2AqGXbh8ncGhrDRcicUe3WSDzjgPY2oTA'), + address('6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN'), + address('6YawcNeZ74tRyCv4UfGydYMr7eho7vbUR6ScVffxKAb3'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('ComputeBudget111111111111111111111111111111'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + address('GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ'), + address('GZsNmWKbqhMYtdSkkvMdEyQF9k5mLmP7tTKYWZjcHVPE'), + address('HY5DRuc8ckTipvzgKYKoEwRxv1X29iLuBLZoxFfE1RwX'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('SysvarC1ock11111111111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [ + { + accountKey: address('6GqRFWez6gvo8yPcj421ZpkhQ2N8ZZFeNezWVj46A58j'), + readableIndexes: [107, 108, 8], + writableIndexes: [106, 117, 111, 116, 105, 104], + }, + { + accountKey: address('EVvmYfSrZ2FEaVj6gy4zanhptAyWeQAkwVjxpNU7ikDi'), + readableIndexes: [81], + writableIndexes: [79, 83, 82], + }, + { + accountKey: address('Gd4QckpGyrmxfqwryNrdMp1ZxHwut162X3t1b3AoeAUL'), + readableIndexes: [18, 16, 15], + writableIndexes: [19, 13, 14, 17], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 15, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [22, 20], + data: '1Wcog6GC9QCw' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: null, + }, + { + accounts: [], + data: 'H4xyQb' as Base58EncodedBytes, + programIdIndex: 16, + stackHeight: null, + }, + { + accounts: [], + data: '3gJqkocMWaMm' as Base58EncodedBytes, + programIdIndex: 16, + stackHeight: null, + }, + { + accounts: [0, 1, 10, 13, 9, 23], + data: '2' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: null, + }, + { + accounts: [4, 13, 1, 0], + data: 'g6wxXSFr4ddxH' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: null, + }, + { + accounts: [0, 3, 12, 13, 9, 23], + data: '2' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: null, + }, + { + accounts: [4, 13, 3, 0], + data: 'iQaRUCA551Mth' as Base58EncodedBytes, + programIdIndex: 23, + stackHeight: null, + }, + { + accounts: [0, 2, 0, 38, 9, 23], + data: '2' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: null, + }, + { + accounts: [ + 23, 18, 0, 4, 8, 5, 2, 13, 38, 21, 21, 17, 21, 40, 18, 30, 32, 31, + 8, 6, 23, 43, 42, 34, 19, 14, 36, 33, 7, 6, 35, 41, 43, 18, 23, + 39, 23, 18, 29, 5, 24, 7, 25, 28, 26, 27, 37, + ], + data: '2c6R1b1rL9gFMrxfe1nBmwGYJV9DvjmDmJETR9i8YiXRUoUUa3q5a8v6Uu7SKMBn7' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'Di1iRQCxiu1FCTSC76uRVnmfN3DNDKzwCMaUQUBZMXUA', + ), + }, + signatures: [ + '4VhDRLUK5QDZ6kgN9PCeEoztUraCibwYA3XaLZUKhfwWxqeN96Qg7Ep4w2j5C1VtggbuU6dqkGczGC537byu9hG3' as Base58EncodedBytes, + ], + }, + version: 0, + } as SolanaTransaction, + id: 1, +}; + +export const EXPECTED_SWAP_TRUMP_TO_JUP_DATA: SolanaTransaction = + RPC_RESPONSE_SWAP_TRUMP_TO_JUP_DATA.result; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-cobie.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-cobie.ts new file mode 100644 index 00000000..32da95d1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-cobie.ts @@ -0,0 +1,557 @@ +import type { + Base58EncodedBytes, + StringifiedBigInt, + StringifiedNumber, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Swap + * Transaction: 2pfnv4drhnitfzCFKxiRoJMzFQpG7wZ9mpRQVk7xm5TQ27g6FZH95HVF6KgwQBS872yGtyhuq57jXXS1y29ub11 + * + * Fee Payer: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa pays 0.000074798 SOL + * + * Senders: + * 8kR2HTHzPtTJuzpFZ8jtGCQ9TpahPaWbZfTNRs2GJdxq sends 0.000073111 SOL - OK + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa sends 0.01 USDC - OK + * HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR sends 2583.728601 Cobie - OK + * 3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL sends 0.000073111 WSOL - OK + * + * Receivers: + * CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM gets 0.000000723 SOL - OK + * HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR gets 0.00007238 SOL - OK + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa gets 2583.728601 Cobie - OK + * 3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL gets 0.01 USDC - OK + */ +export const EXPECTED_SWAP_USDC_TO_COBIE_DATA: SolanaTransaction = { + blockTime: 1740480781n as UnixTimestamp, + meta: { + computeUnitsConsumed: 168678n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(74798n), + innerInstructions: [ + { + index: 3, + instructions: [ + { + accounts: [32], + data: '84eT' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [0, 4], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 2, + }, + { + accounts: [4], + data: 'P' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [4, 32], + data: '6a249gQLTGr8sLfBkuZcHsxRCAz3FMHEBiRB27PpboKoY' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + ], + }, + { + index: 4, + instructions: [ + { + accounts: [ + 25, 31, 26, 28, 2, 4, 32, 30, 27, 31, 0, 24, 24, 29, 31, 1, 10, 7, + ], + data: 'PgQWtn8oziwrnLY8ghxq7vQsihwtL9QE3' as Base58EncodedBytes, + programIdIndex: 31, + stackHeight: 2, + }, + { + accounts: [2, 30, 28, 0], + data: 'gJsbCroi1ByE5' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 3, + }, + { + accounts: [26, 32, 4, 25], + data: 'i2WXXF95iYPqi' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 3, + }, + { + accounts: [29], + data: 'yCGxBopjnVNQkNP5usq1PnuX63UpLUMAPbtb9tJ3pXKjaHfWnNzzWHRPvjXskAMby8QzL7jngVBJzowbhPqw3GCU9bdYaq1SNERcWsZSzZfa7fPx7FHfQuWQws8nhB6J6WzXC48pM8nKLyvnAFFMu8TrpY8ZL4Jwjz7SNz2UStYteyQGk8skE5s7w2Sg4EVGsuhsR9' as Base58EncodedBytes, + programIdIndex: 31, + stackHeight: 3, + }, + { + accounts: [19], + data: 'QMqFu4fYGGeUEysFnenhAvBobXTzswhLdvQq6s8axxcbKUPRksm2543pJNNNHVd1VLAi4qh5j7rwZtCSWuuB1aXiEonpsPqhURndSP2a5qqFkPQqejwJQTqnUyXc5qjyJwjNKaEYwcfUQ5uUzbSsMpK4rL5UA2jBbCvT9EDGhgrojJX' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + { + accounts: [0, 3], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 2, + }, + { + accounts: [3, 32], + data: '6a249gQLTGr8sLfBkuZcHsxRCAz3FMHEBiRB27PpboKoY' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [4, 3, 0], + data: '3ejYvDxcKsqy' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [3, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 2, + }, + { + accounts: [13, 6, 20, 9, 5, 8, 0, 11, 24, 23, 17, 14, 4, 3, 32], + data: 'AJTQ2h9DXrC61PPEdjAfMtRZWohLqNTPu' as Base58EncodedBytes, + programIdIndex: 14, + stackHeight: 2, + }, + { + accounts: [5, 8, 9], + data: '3qrEGggrVejy' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: 3, + }, + { + accounts: [0, 9], + data: '3Bxs4YKUs5qBX2ej' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [0, 6], + data: '3Bxs4c9haNdKxXbV' as Base58EncodedBytes, + programIdIndex: 11, + stackHeight: 3, + }, + { + accounts: [17], + data: '2K7nL28PxCW8ejnyCeuMpbYAoNKGT55WwkL2oJ73Tbi8axFGrsXZ1FfEnJxbuVatPyMswJbFxnVR1TFw9KkbC2cgyDuqA7sb6FTAJXEi8tniqjcGFD1yfyzdyi959cuHhuxHxYSHpLE1j2wWCuAgTke66nkafFYFd6XYB5Fp6ogZ9qdj69Smdbk1FZ59' as Base58EncodedBytes, + programIdIndex: 14, + stackHeight: 3, + }, + { + accounts: [19], + data: 'QMqFu4fYGGeUEysFnenhAvB8g5D9B5PjuGNqrAHPq2uCC4Sa4PqBTMWJ3PXjCTgxpfjESUC2zvPwWpQ5yRFSVffzjgfMf1zAj4jV5mMmQqLGwpsTZwWBFebKvRDEz86J43UiVLgEiTWPs8XSg2Tfbk53dkiQAgzpeqCtB8HqhQnXmc3' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6'), + address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + address('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'), + address('So11111111111111111111111111111111111111112'), + ], + writable: [ + address('3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL'), + address('8kR2HTHzPtTJuzpFZ8jtGCQ9TpahPaWbZfTNRs2GJdxq'), + address('8mLREQqtxf9yashib1PQTKFqQYaHWVUnLHkaNJxKYEkk'), + address('EeThDNkUuNhJFHYqR3yTB6wzcj1hrubgVQuvSSGjNt4W'), + ], + }, + logMessages: [ + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br invoke [1]', + 'Program log: LI.FI TX: 0x111BD1229C464900', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br consumed 4537 of 279190 compute units', + 'Program 3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]', + 'Program log: CreateIdempotent', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: GetAccountDataSize', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1569 of 268948 compute units', + 'Program return: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA pQAAAAAAAAA=', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program log: Initialize the associated token account', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeImmutableOwner', + 'Program log: Please upgrade to SPL Token 2022 for immutable owner support', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 1405 of 262361 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 258479 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 19315 of 274353 compute units', + 'Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: Route', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [2]', + 'Program log: Instruction: Swap', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6200 of 211271 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: TransferChecked', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 6238 of 201637 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke [3]', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 2134 of 191965 compute units', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo consumed 59239 of 247502 compute units', + 'Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 186531 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program 11111111111111111111111111111111 invoke [2]', + 'Program 11111111111111111111111111111111 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: InitializeAccount3', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3158 of 174816 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4735 of 170426 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 164476 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [2]', + 'Program log: Instruction: Buy', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 133954 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program 11111111111111111111111111111111 invoke [3]', + 'Program 11111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [3]', + 'Program 11111111111111111111111111111111 success', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P invoke [3]', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 2132 of 121384 compute units', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success', + 'Program data: vdt/007mYe72Rs5CbVGL4yPT4OA3oflm+MZTm2VeAAPaq82FL/Kmj7waAQAAAAAA2ZEAmgAAAAABv3QE8x5DFsJ3BJvILaW/8UbfAT/bwws3kZNRhGKS9EMNob1nAAAAALl95P0GAAAAJfUnk+7OAwC50cABAAAAACVdFUdd0AIA', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P consumed 40763 of 157709 compute units', + 'Program 6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 115201 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 141611 of 255038 compute units', + 'Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 2ZEAmgAAAAA=', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]', + 'Program log: Instruction: CloseAccount', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2915 of 113427 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + ], + postBalances: [ + lamports(54120523n), + lamports(71437440n), + lamports(2039280n), + lamports(0n), + lamports(0n), + lamports(2039280n), + lamports(95830848371560n), + lamports(71437440n), + lamports(2039280n), + lamports(30645737n), + lamports(71437440n), + lamports(1n), + lamports(1141440n), + lamports(284137320n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(137104014n), + lamports(1n), + lamports(0n), + lamports(1461600n), + lamports(1141440n), + lamports(1169280n), + lamports(1009200n), + lamports(934087680n), + lamports(10882836n), + lamports(19607032850n), + lamports(23385600n), + lamports(2039280n), + lamports(4000000n), + lamports(377687613311n), + lamports(1141440n), + lamports(990717908551n), + ], + postTokenBalances: [ + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4958521' as StringifiedBigInt, + decimals: 6, + uiAmount: 4.958521, + uiAmountString: '4.958521' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump'), + owner: address('HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '998948996539685' as StringifiedBigInt, + decimals: 6, + uiAmount: 998948996.539685, + uiAmountString: '998948996.539685' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '7460220079' as StringifiedBigInt, + decimals: 6, + uiAmount: 7460.220079, + uiAmountString: '7460.220079' as StringifiedNumber, + }, + }, + { + accountIndex: 26, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '19604993570' as StringifiedBigInt, + decimals: 9, + uiAmount: 19.60499357, + uiAmountString: '19.60499357' as StringifiedNumber, + }, + }, + { + accountIndex: 28, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1358798485' as StringifiedBigInt, + decimals: 6, + uiAmount: 1358.798485, + uiAmountString: '1358.798485' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(54195313n), + lamports(71437440n), + lamports(2039280n), + lamports(0n), + lamports(0n), + lamports(2039280n), + lamports(95830848370837n), + lamports(71437440n), + lamports(2039280n), + lamports(30573357n), + lamports(71437440n), + lamports(1n), + lamports(1141440n), + lamports(284137320n), + lamports(1141440n), + lamports(731913600n), + lamports(0n), + lamports(137104014n), + lamports(1n), + lamports(0n), + lamports(1461600n), + lamports(1141440n), + lamports(1169280n), + lamports(1009200n), + lamports(934087680n), + lamports(10882836n), + lamports(19607105961n), + lamports(23385600n), + lamports(2039280n), + lamports(4000000n), + lamports(377687613311n), + lamports(1141440n), + lamports(990717908551n), + ], + preTokenBalances: [ + { + accountIndex: 2, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4968521' as StringifiedBigInt, + decimals: 6, + uiAmount: 4.968521, + uiAmountString: '4.968521' as StringifiedNumber, + }, + }, + { + accountIndex: 5, + mint: address('HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump'), + owner: address('HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '998951580268286' as StringifiedBigInt, + decimals: 6, + uiAmount: 998951580.268286, + uiAmountString: '998951580.268286' as StringifiedNumber, + }, + }, + { + accountIndex: 8, + mint: address('HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump'), + owner: address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '4876491478' as StringifiedBigInt, + decimals: 6, + uiAmount: 4876.491478, + uiAmountString: '4876.491478' as StringifiedNumber, + }, + }, + { + accountIndex: 26, + mint: address('So11111111111111111111111111111111111111112'), + owner: address('3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '19605066681' as StringifiedBigInt, + decimals: 9, + uiAmount: 19.605066681, + uiAmountString: '19.605066681' as StringifiedNumber, + }, + }, + { + accountIndex: 28, + mint: address('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), + owner: address('3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL'), + programId: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + uiTokenAmount: { + amount: '1358788485' as StringifiedBigInt, + decimals: 6, + uiAmount: 1358.788485, + uiAmountString: '1358.788485' as StringifiedNumber, + }, + }, + ], + rewards: [], + status: { + Ok: null, + }, + }, + slot: 322982554n, + transaction: { + message: { + accountKeys: [ + address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + address('2KGdYZf3znzS7DRMRVpuMWzErUEqxG2oWeBTrboTE8kR'), + address('3QE7UkXbrHxEbi373Gp5hwPQwJaAVoLvwYBRz5jwaGxt'), + address('8cGQVXQQtvqAVHEHJfAzQL4saJTk3QQcX5qcQvz2GF3p'), + address('8Ne212dQ5vJTZGB3RkGE5YmMePRiFYjcyrBc1egt7hK5'), + address('Bg55CFRuAfbH5r86Y3UwEdvGZytNYSNKzWkW7jYC7jQU'), + address('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM'), + address('CzfDWVUJ68SuwSztnUb59TuNBAG2wNnyzRwW16xNtVpN'), + address('HaMfinLwpZnjntMjbJa1D6WhVyRgZQXYzVCGSVrhG6rD'), + address('HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR'), + address('KGN26tp8yVRLZZvHWQhpfJTkoz4EnrNbo8Fg4ybA4Hg'), + address('11111111111111111111111111111111'), + address('3i5JeuZuUxeKtVysUnwQNGerJP2bSMX9fTFfS4Nxe3Br'), + address('4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf'), + address('6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'), + address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'), + address('Bim4yRBjmqTUuPShNRSGFu8atnTMED4TP5aTyNtqfM2B'), + address('Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1'), + address('ComputeBudget111111111111111111111111111111'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + address('HaMv3cdfDW6357yjpDur6kb6w52BUPJrMJpR76tjpump'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('SysvarC1ock11111111111111111111111111111111'), + address('SysvarRent111111111111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [22, 16], + data: '13ryaMXfdn7H' as Base58EncodedBytes, + programIdIndex: 12, + stackHeight: null, + }, + { + accounts: [], + data: 'HvpyeP' as Base58EncodedBytes, + programIdIndex: 18, + stackHeight: null, + }, + { + accounts: [], + data: '3dgRf8s6ueV5' as Base58EncodedBytes, + programIdIndex: 18, + stackHeight: null, + }, + { + accounts: [0, 4, 0, 32, 11, 24], + data: '2' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: null, + }, + { + accounts: [ + 24, 0, 2, 8, 21, 20, 21, 19, 21, 31, 25, 31, 26, 28, 2, 4, 32, 30, + 27, 31, 0, 24, 24, 29, 31, 1, 10, 7, 21, 14, 13, 6, 20, 9, 5, 8, 0, + 11, 24, 23, 17, 14, 4, 3, 32, + ], + data: '3aafXU8vKpJ2E1MADRM3pFuPJkHrkTThMg8qRft7gjwU8A7ANt32uu' as Base58EncodedBytes, + programIdIndex: 21, + stackHeight: null, + }, + { + accounts: [4, 0, 0], + data: 'A' as Base58EncodedBytes, + programIdIndex: 24, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + 'Bws7upN75U93zxs4sRxhDnZ8gZTGhtP17emW7DtxxJKt', + ), + }, + signatures: [ + '2pfnv4drhnitfzCFKxiRoJMzFQpG7wZ9mpRQVk7xm5TQ27g6FZH95HVF6KgwQBS872yGtyhuq57jXXS1y29ub11', + ] as Base58EncodedBytes[], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-jup.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-jup.ts new file mode 100644 index 00000000..f544f465 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-usdc-to-jup.ts @@ -0,0 +1,352 @@ +import type { + Address, + Base58EncodedBytes, + Slot, + StringifiedBigInt, + StringifiedNumber, + UnixTimestamp, +} from '@solana/kit'; +import { address, blockhash, lamports } from '@solana/kit'; + +import type { SolanaTransaction } from '../../../types/solana'; + +/** + * Mainnet - Swap + * Transaction: 5LuTa5k9UvgM2eJknVUD9MjfcmcTP7nvFrCedU8d7ZLXCHbrrwqhXDQYTSfncm1wTSNFPZj3Y4cRkWC8CLG6Zcvh + * + * Fee Payer: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa pays 0.000074798 SOL + * + * Senders: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa sends 1.1 USDC + * + * Receivers: + * DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa gets 2.143812 JUP + * + */ +export const EXPECTED_SWAP_USDC_TO_JUP_DATA: SolanaTransaction = { + blockTime: 1742297902n as UnixTimestamp, + meta: { + computeUnitsConsumed: 61355n, + // eslint-disable-next-line id-denylist + err: null, + fee: lamports(80001n), + innerInstructions: [ + { + index: 2, + instructions: [ + { + accounts: [1, 3, 0], + data: '3bttrrSseTqq' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: 2, + }, + { + accounts: [7], + data: '2qWhKzSZDTHhTkHUC1NYnTjhAu5u5AHWvB5YkwobfvwkhzLAN4fRrYt3YWSuCBjhKsmhdPfwmMZEoy3TiqnMvuU8McaZeXqyoQemK1mDgnfp2z3rnpnkQgM5Z' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: 2, + }, + { + accounts: [9, 11, 10, 12, 13, 1, 2, 0, 15, 14], + data: '4Ts9LwHY3pMtj4MjmTzmKu1' as Base58EncodedBytes, + programIdIndex: 16, + stackHeight: 2, + }, + { + accounts: [1, 10, 0], + data: '3UctjiT2vt9m' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: 3, + }, + { + accounts: [13, 2, 13], + data: '3Qx9e2USBsUT' as Base58EncodedBytes, + programIdIndex: 15, + stackHeight: 3, + }, + { + accounts: [7], + data: 'QMqFu4fYGGeUEysFnenhAvCS196iqPeVBXisaezTjxHaPsVUKhQ7dGWecKHhLP29PQen8zkCfLVaJVey8xQeBj6JMRuZiVT5RFhaoRYGBdbJJGiF7gDdAzFU3Etb4NbGV7d5YgRSYwo6wYsaymaneEQrcVnYMgvtWV55jUKWY6sdcE3' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: 2, + }, + ], + }, + ], + loadedAddresses: { + readonly: [ + address('Sysvar1nstructions1111111111111111111111111'), + address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + address('ZERor4xhbUycZ6gb9ntrhqscUcZmAbQDjEAtCf4hbZY'), + ], + writable: [ + address('1amiJLvkVHjPz7t8dwBsWHknHitcpqwPPuuUCfHyzjB'), + address('8cjeuVV3KQ9k8RqW1JUyCfey2TDAhuo7f4hPDMeGfxv'), + address('3YTcuZp6cuT9VvdeTJ8wK5apjZX1Mz8FZAZbhX28uj4L'), + address('4oeU2MYpcLRpKxuNnsDue8HawERCrCce4pswasL4EkEm'), + address('HWcEXBF7Gu2HnjBPfrSYpUKYrV4bBrkMJRWnb5Qn1zUe'), + ], + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [1]', + 'Program log: Instruction: Route', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4644 of 72312 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 66293 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program ZERor4xhbUycZ6gb9ntrhqscUcZmAbQDjEAtCf4hbZY invoke [2]', + 'Program log: Instruction: swap', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4645 of 47573 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [3]', + 'Program log: Instruction: Transfer', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4554 of 40788 compute units', + 'Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success', + 'Program data: Q3AAHurUU32hq/7cNFJFoKPTxDE76dGiDn/896crKwjhqPh10wksYSSzBkRclFF8C+yNEehgubDZCqAglD9uT7sLKCifBPJ3GwK/yODnPr+hH2f+1V2cVqy6Zuj7KgnRuRG6NrI4KJVBEFPc74cJwEbZ9oaJ31u0yZA0jtKppcJCcAAe6tRTffspwY/LvptLRldW4xF3e9FxbSX54eAxcFlQR/l+0ZYzm8cCt0LXR7586BbZxcUGQZ/VoR9P/GV4Kph5rP2WBjRBpq/I4Oc+v+WpR/7VXZxWxvEh//Ns6e65Ebo2sjjYqmxLirvvhwnARtn2honfW7TJkDSO0qmlwkJwAB7q1FN9oY3Rj8u+m0sC4XbjEXd70Z1jJfnh4DFwGRJI+X7RljObxwK3QtdHvnzoFtnFxQZBn9WhH0/8ZXgqmHms/ZYGNEGmr8jg5z6/5alH/tVdnFbG8SH/82zp7rkRujayONiqbEuKu++HCcBG2faGid9btMmQNI7SqaXCQnAAHurUU32hjdGPy76bSwLhduMRd3vRnWMl+eHgMXAZEkj5ftGWM5vHArdC10e+fOgW2cXFBkGf1aEfT/xleCqYeaz9lgY0QaavyODnPr/lqUf+1V2cVsbxIf/zbOnuuRG6NrI42KpsS4q774cJwEbZ9oaJ31u0yZA0jtKppcJCcAAe6tRTfaGN0Y/LvptLAuF24xF3e9GdYyX54eAxcH8XSPl+0ZYzd8kCt0LXR76U6xbZxcUGQZ/VoR9P/GV4Kph5rP2WBjRBpq/I4Oc+v+WpR/7VXZxWxvEh//Ns6e65Ebo2sjjYqmxLirvvhwnARtn2honfW7TJkDSO0qmlwkJwAB7q1FN9oY3Rj8u+m0sC4XbjEXd70Q==', + 'Program ZERor4xhbUycZ6gb9ntrhqscUcZmAbQDjEAtCf4hbZY consumed 45476 of 63205 compute units', + 'Program ZERor4xhbUycZ6gb9ntrhqscUcZmAbQDjEAtCf4hbZY success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke [2]', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 184 of 15994 compute units', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 consumed 61055 of 75283 compute units', + 'Program return: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 RLYgAAAAAAA=', + 'Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 success', + ], + postBalances: [ + lamports(47568321n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(1n), + lamports(1141440n), + lamports(69942826115n), + lamports(0n), + lamports(0n), + lamports(52784640n), + lamports(2039280n), + lamports(8352000n), + lamports(8352000n), + lamports(2039280n), + lamports(0n), + lamports(934087680n), + lamports(1141440n), + ], + postTokenBalances: [ + { + accountIndex: 1, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '1828521' as StringifiedBigInt, + decimals: 6, + uiAmount: 1.828521, + uiAmountString: '1.828521' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' as Address, + owner: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '4881197' as StringifiedBigInt, + decimals: 6, + uiAmount: 4.881197, + uiAmountString: '4.881197' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '9yj3zvLS3fDMqi1F8zhkaWfq8TZpZWHe6cz1Sgt7djXf' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '4029370153254' as StringifiedBigInt, + decimals: 6, + uiAmount: 4029370.153254, + uiAmountString: '4029370.153254' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '8cjeuVV3KQ9k8RqW1JUyCfey2TDAhuo7f4hPDMeGfxv' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '532496448015' as StringifiedBigInt, + decimals: 6, + uiAmount: 532496.448015, + uiAmountString: '532496.448015' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' as Address, + owner: 'HWcEXBF7Gu2HnjBPfrSYpUKYrV4bBrkMJRWnb5Qn1zUe' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '140193384137' as StringifiedBigInt, + decimals: 6, + uiAmount: 140193.384137, + uiAmountString: '140193.384137' as StringifiedNumber, + }, + }, + ], + preBalances: [ + lamports(47648322n), + lamports(2039280n), + lamports(2039280n), + lamports(2039280n), + lamports(1n), + lamports(1141440n), + lamports(69942826115n), + lamports(0n), + lamports(0n), + lamports(52784640n), + lamports(2039280n), + lamports(8352000n), + lamports(8352000n), + lamports(2039280n), + lamports(0n), + lamports(934087680n), + lamports(1141440n), + ], + preTokenBalances: [ + { + accountIndex: 1, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '2928521' as StringifiedBigInt, + decimals: 6, + uiAmount: 2.928521, + uiAmountString: '2.928521' as StringifiedNumber, + }, + }, + { + accountIndex: 2, + mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' as Address, + owner: 'DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '2737385' as StringifiedBigInt, + decimals: 6, + uiAmount: 2.737385, + uiAmountString: '2.737385' as StringifiedNumber, + }, + }, + { + accountIndex: 3, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '9yj3zvLS3fDMqi1F8zhkaWfq8TZpZWHe6cz1Sgt7djXf' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '4029370143904' as StringifiedBigInt, + decimals: 6, + uiAmount: 4029370.143904, + uiAmountString: '4029370.143904' as StringifiedNumber, + }, + }, + { + accountIndex: 10, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Address, + owner: '8cjeuVV3KQ9k8RqW1JUyCfey2TDAhuo7f4hPDMeGfxv' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '532495357365' as StringifiedBigInt, + decimals: 6, + uiAmount: 532495.357365, + uiAmountString: '532495.357365' as StringifiedNumber, + }, + }, + { + accountIndex: 13, + mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' as Address, + owner: 'HWcEXBF7Gu2HnjBPfrSYpUKYrV4bBrkMJRWnb5Qn1zUe' as Address, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address, + uiTokenAmount: { + amount: '140195527949' as StringifiedBigInt, + decimals: 6, + uiAmount: 140195.527949, + uiAmountString: '140195.527949' as StringifiedNumber, + }, + }, + ], + // returnData: { + // data: 'RLYgAAAAAAA=' as Base64EncodedDataResponse, + // programId: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4' as Address, + // }, + rewards: [], + status: { Ok: null }, + }, + slot: 327561062n, + transaction: { + message: { + accountKeys: [ + address('DtMUkCoeyzs35B6EpQQxPyyog6TRwXxV1W1Acp8nWBNa'), + address('3QE7UkXbrHxEbi373Gp5hwPQwJaAVoLvwYBRz5jwaGxt'), + address('BWqMUT5533X8M4DM3AQvFeAQd451urcVQu3zi6uVxWtT'), + address('J8gt2jBUi6DW6gdUdm1fkP9oGN4xXwPdQbCZkkN78R3Z'), + address('ComputeBudget111111111111111111111111111111'), + address('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + address('JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'), + address('D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf'), + address('J56q6nX15WHRLJcsGB6s1bjaiywrn8DqLLvLccz61cYx'), + ], + addressTableLookups: [ + { + accountKey: address('37vuMG2KFv3FjzeX2dH8HB3hjvAdxBPknMMMCQoCGoKC'), + readableIndexes: [44, 9, 39], + writableIndexes: [46, 45, 41, 43, 42], + }, + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 5, + numRequiredSignatures: 1, + }, + instructions: [ + { + accounts: [], + data: 'Fhgyu1' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [], + data: '3JffhtY91DCs' as Base58EncodedBytes, + programIdIndex: 4, + stackHeight: null, + }, + { + accounts: [ + 15, 0, 1, 2, 5, 6, 3, 7, 5, 16, 9, 11, 10, 12, 13, 1, 2, 0, 15, 14, + 8, + ], + data: 'PrpFmsY4d26dKbdKNQQjhrmGEpkbkfkFrSJda7D9TvWC2e52' as Base58EncodedBytes, + programIdIndex: 5, + stackHeight: null, + }, + ], + recentBlockhash: blockhash( + '75B7mDyDb7pFTYWuDf5vehM2oHW8keS5VnrTTr83ko5k', + ), + }, + signatures: [ + '5LuTa5k9UvgM2eJknVUD9MjfcmcTP7nvFrCedU8d7ZLXCHbrrwqhXDQYTSfncm1wTSNFPZj3Y4cRkWC8CLG6Zcvh' as Base58EncodedBytes, + ], + }, + version: 0, +}; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap.ts new file mode 100644 index 00000000..4b606b9a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap.ts @@ -0,0 +1,2 @@ +export const MOCK_VALID_SWAP_TRANSACTION = + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAJGb90BPMeQxbCdwSbyC2lv/FG3wE/28MLN5GTUYRikvRDkOL72EsPrSrrKZF33sPiMFwhF786GU/O6Np6ngUZdtMjqo7S3idbRg4oDnEPLya1vPuQf89zrLobei3jVynGDWZ/y1OFUjaxGSYPYSYxL38DI5jSwryQ6RMhtMx+2NckC3UgzyHnGpR6VJ/vU2WjD5DjwsvxD8xvVd650NGXdFPfFDaFT76ZIor8z07o9ErtPu3r9GAOUB8XlcFOWqe0GbVg2nwBF4l0ttMopHOlyK+KfsPeVOMeRyLf74oiXgZpxRtTT3ZTysIJA79qg1dQT8Wj+GTHAMGTFDs3n24716MkP6jxjf4+aNNNmPpXtUfvmxMIAY7riPwpO4SFhpVj0XEzfZHfK+df8cHOiMH18Ck85+5FYvbuPgRfoH+q0xHdtI87EL3RhZBcQk4vzHrzbkAkE7DwtVHIo8xFIraXVSCz5bV/G3n7wr0zknZABvXObI/TVq0OyaCULUJJ+4wRxu/Ab3BgJLxDIWyhxeghwuw3hQHQsddhorJLkDoHnFEe4QbQHUu/XbvkfR9I9VbH7APhtv1iOmKh8B4KL0eLA2qtZCdRkuyZUpEk9d4ij6b7goSocmdwGNZYeO9YHrIxsBTGnIKVMZIov5wu5RjgHDxo2EPhQveTgkkiYamH6wF3KD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FUGp9UXGMd0yShWY5hpHV62i164o5tLbVxzVVshAAAAAEkKjsh0MV4N5BZqrYoVn502rqoySfW65Wb3zHaXL7aVAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+agAv/TIc2iJbCD8FAc+vxy1qjdf6B/k29yCuk37deeLQ/+if11/ZKdMCbHylYed5LCas238ndUUsyGqezjOXo5K2meepn3qneL1SbuOijeqwwK3lRyEWrdscvyuCXNhUFEAIREgkAcW2PgwH1yAATAAUCkaMFABMACQP7LQIAAAAAABQGAAEAJRUmAQEWSSYXAAIDBAEnJRYWGBYoHSgeHwMFKycgKBcmJikoBgcIFighKCIjBQkrKiQoFyYmKSgKCwwWKBkoGhsJBCUqHCgXJiYpKA0ODxYswSCbM0HWnIECAwAAACZkAAEmZAECJmQCA0BCDwAAAAAAFBlnfQEAAAAyAAADL2c++byrysDHksR/hJ46kiqc8he+GbowBx6uV7CZWyME29zZ4AbaAJwBA0K46+6aQbZXU5t0wJLWiAcN0mjgFssRN0gyDYS+vpMa2ATMb3LOAXEKxf5RTUsVyCew1xJHNL+DxGC6MsqRsYXEZ/tnyaBY0QRcmV1bAA=='; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions.ts new file mode 100644 index 00000000..6176a073 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/transactions.ts @@ -0,0 +1,42 @@ +export const MOCK_GET_SIGNATURES_FOR_ADDRESS = [ + { + signature: + '3B7H4E2ih3Tcas6um1izEBZagVfLoxSUfZSKkSNSu7mh4nAy7ZafaEgKhH4d1NBY2MMRWgyPX2LcMbKYwphR8dRq', + }, + { + signature: + '3Zj5XkvE1Uec1frjue6SK2ND2cqhKPvPkZ1ZFPwo2v9iL4NX4b4WWG1wPNEQdnJJU8sVx7MMHjSH1HxoR21vEjoV', + }, + { + signature: + '2qfNzGs15dt999rt1AUJ7D1oPQaukMPPmHR2u5ZmDo4cVtr1Pr2Dax4Jo7ryTpM8jxjtXLi5NHy4uyr68MVh5my6', + }, + { + signature: + '54Lz5p2zQNU6ngvyGtpeMYEdGoHG2D7ByPS2n3Wa4QNHzqTZ46sUemk1PxSrM6UieQ2i15XiRrTuxZyiPkg8V1vW', + }, + { + signature: + '2a5UXcyb6Gz8DH5MdumBvoGQiHLjTKfPcKrAGcsPrVSUjM9NRVUB1TuL1sNEj59nKBzfLm3Z2RvtsnCGZHa7KXPB', + }, + { + signature: + 'yftYXx1xSmLiMeJ2mGkpZd7Xd13mtW7juWcRnihMhDz1zAeCrq5rPrw7WoCkhEcfUL7MwYCti9Q8bWRdJKZuris', + }, + { + signature: + '24pkWA6oUqtKs1nqx4ZFqW3DoeNcVHC57s1azr63EzaXsDNJAkejmyjB7QonVqvm3cC8cVtbN11jSWTu1xUurQZ9', + }, + { + signature: + '27kCW7f9RCWDkQSqSDrwvbJ3d8mgaFmLLu7GsVujJnp55ue8mQNHvphoVEEF32mXUWZSagdXNraZ7zszBENgAY7T', + }, + { + signature: + '5XpBS9D4bBhc4F69SJd3th19Xe8qhqPyJ3MKWhRLF3tbeHTbSLZSM9UUztJc7pLTASUd2jNR67y2W3Q6LogUnai7', + }, + { + signature: + '5iFQpCwAgiXebzuKxLfhePscR9EYRvRNRSx2Mbj12ed36zNkGmQMkg7ekFXjh88R3p75D6uNK45hgRxC6FyUDnhE', + }, +]; diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/deriveSolanaKeypair.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/deriveSolanaKeypair.ts new file mode 100644 index 00000000..840380d5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/deriveSolanaKeypair.ts @@ -0,0 +1,28 @@ +import { + MOCK_SEED_PHRASE_ENTROPY_SOURCE, + MOCK_SOLANA_KEYRING_ACCOUNTS, + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES, +} from '../solana-keyring-accounts'; + +export const deriveSolanaKeypairMock = jest + .fn() + .mockImplementation((params) => { + const { entropySource = MOCK_SEED_PHRASE_ENTROPY_SOURCE, derivationPath } = + params; + + const account = MOCK_SOLANA_KEYRING_ACCOUNTS.find( + (currentAccount) => + entropySource === currentAccount.entropySource && + derivationPath === currentAccount.derivationPath, + ); + + if (!account) { + throw new Error('[deriveSolanaAddress] Not enough mocked indices'); + } + + return { + privateKeyBytes: + MOCK_SOLANA_KEYRING_ACCOUNTS_PRIVATE_KEY_BYTES[account.id], + publicKeyBytes: null, // We don't need public key bytes for the tests + }; + }); diff --git a/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts b/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts new file mode 100644 index 00000000..01d8a823 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts @@ -0,0 +1,40 @@ +import type { SLIP10PathNode, SupportedCurve } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; + +import { + MOCK_SEED_PHRASE_2_BYTES, + MOCK_SEED_PHRASE_2_ENTROPY_SOURCE, + MOCK_SEED_PHRASE_BYTES, +} from '../solana-keyring-accounts'; + +export const getBip32EntropyMock = jest + .fn() + .mockImplementation( + async ({ + path, + curve, + entropySource, + }: { + path: string[]; + curve: SupportedCurve; + entropySource?: string; + }) => { + if (entropySource === MOCK_SEED_PHRASE_2_ENTROPY_SOURCE) { + return await SLIP10Node.fromDerivationPath({ + derivationPath: [ + MOCK_SEED_PHRASE_2_BYTES, + ...path.slice(1).map((node) => `slip10:${node}` as SLIP10PathNode), + ], + curve, + }); + } + + return await SLIP10Node.fromDerivationPath({ + derivationPath: [ + MOCK_SEED_PHRASE_BYTES, + ...path.slice(1).map((node) => `slip10:${node}` as SLIP10PathNode), + ], + curve, + }); + }, + ); diff --git a/merged-packages/solana-wallet-snap/src/core/test/utils.ts b/merged-packages/solana-wallet-snap/src/core/test/utils.ts new file mode 100644 index 00000000..2d2b3d45 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/test/utils.ts @@ -0,0 +1 @@ +export const TEST_ORIGIN = 'http://localhost:3000'; diff --git a/merged-packages/solana-wallet-snap/src/core/types/error.ts b/merged-packages/solana-wallet-snap/src/core/types/error.ts new file mode 100644 index 00000000..6c722e32 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/types/error.ts @@ -0,0 +1,4 @@ +export type FormFieldError = { + message: string; + value: Field; +} | null; diff --git a/merged-packages/solana-wallet-snap/src/core/types/form.ts b/merged-packages/solana-wallet-snap/src/core/types/form.ts new file mode 100644 index 00000000..b4774398 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/types/form.ts @@ -0,0 +1,13 @@ +import type { LocalizedMessage } from '../utils/i18n'; +import type { FormFieldError } from './error'; + +export type FormState = Record< + FormNames, + string | number | boolean | null +>; + +export type FieldValidationFunction = (value: string) => FormFieldError; +export type ValidationFunction = ( + message: LocalizedMessage, + value?: any, +) => FieldValidationFunction; diff --git a/merged-packages/solana-wallet-snap/src/core/types/snap.ts b/merged-packages/solana-wallet-snap/src/core/types/snap.ts new file mode 100644 index 00000000..4dbd7bfa --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/types/snap.ts @@ -0,0 +1,22 @@ +import type { Locale } from '../utils/i18n'; + +export type Preferences = { + locale: Locale; + currency: string; + hideBalances: boolean; + useSecurityAlerts: boolean; + useExternalPricingData: boolean; + simulateOnChainActions: boolean; + useTokenDetection: boolean; + batchCheckBalances: boolean; + displayNftMedia: boolean; + useNftDetection: boolean; +}; + +/** + * 'initial' - The initial state, where no data has been fetched yet. + * 'fetching' - The state where data is being fetched. + * 'fetched' - The state where data has been fetched successfully. + * 'error' - The state where an error occurred while fetching data. + */ +export type FetchStatus = 'initial' | 'fetching' | 'fetched' | 'error'; diff --git a/merged-packages/solana-wallet-snap/src/core/types/solana.ts b/merged-packages/solana-wallet-snap/src/core/types/solana.ts new file mode 100644 index 00000000..78b24e4f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/types/solana.ts @@ -0,0 +1,77 @@ +import type { + Address, + Base58EncodedBytes, + Base64EncodedDataResponse, + Blockhash, + Lamports, + Reward, + Slot, + TokenBalance, + TransactionError, + TransactionVersion, + UnixTimestamp, +} from '@solana/kit'; + +export type SolanaInstruction = { + accounts: readonly number[]; + data: Base58EncodedBytes; + programIdIndex: number; + stackHeight?: number | null; +}; + +export type SolanaTransaction = { + blockTime: UnixTimestamp | null; + meta: { + computeUnitsConsumed?: bigint | null; + // eslint-disable-next-line id-denylist + err: TransactionError | null; + fee?: Lamports; + logMessages?: readonly string[] | null; + postBalances?: readonly Lamports[]; + postTokenBalances?: readonly TokenBalance[]; + preBalances?: readonly Lamports[]; + preTokenBalances?: readonly TokenBalance[]; + returnData?: { + data: Base64EncodedDataResponse; + programId: Address; + }; + rewards?: readonly Reward[] | null; + // eslint-disable-next-line @typescript-eslint/naming-convention + status?: { Ok: any } | { Err: TransactionError }; + innerInstructions?: + | readonly { + index: number; + instructions: readonly { + accounts: readonly number[]; + data: Base58EncodedBytes; + programIdIndex: number; + stackHeight?: number; + }[]; + }[] + | null; + loadedAddresses?: { + readonly: readonly Address[]; + writable: readonly Address[]; + }; + } | null; + slot: Slot; + transaction: { + message: { + accountKeys: readonly Address[]; + addressTableLookups?: readonly { + accountKey: Address; + readableIndexes: readonly number[]; + writableIndexes: readonly number[]; + }[]; + header: { + numReadonlySignedAccounts: number; + numReadonlyUnsignedAccounts: number; + numRequiredSignatures: number; + }; + instructions: readonly SolanaInstruction[]; + recentBlockhash: Blockhash; + }; + signatures: readonly Base58EncodedBytes[]; + }; + version?: TransactionVersion | null; +}; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/PromiseAny.ts b/merged-packages/solana-wallet-snap/src/core/utils/PromiseAny.ts new file mode 100644 index 00000000..4e685385 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/PromiseAny.ts @@ -0,0 +1,34 @@ +/** + * Replicates the behavior of [Promise.any](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any). + * + * Takes an iterable of promises as input and returns a single Promise. This returned promise fulfills when any of the input's promises fulfills, with this first fulfillment value. It rejects when all of the input's promises reject (including when an empty iterable is passed), with an AggregateError containing an array of rejection reasons. + * + * @param promises - The promises to resolve. + * @returns A promise that resolves to the first resolved promise. + */ +export const PromiseAny = async ( + promises: Promise[], +): Promise => { + return new Promise((resolve, reject) => { + if (promises.length === 0) { + reject(new AggregateError([], 'No promises to resolve')); + return; + } + + let rejectedCount = 0; + const errors: any[] = []; + + promises.forEach((promise, index) => { + Promise.resolve(promise).then( + (value: TReturn) => resolve(value), + (error) => { + errors[index] = error; + rejectedCount += 1; + if (rejectedCount === promises.length) { + reject(new AggregateError(errors, 'All promises rejected')); + } + }, + ); + }); + }); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.test.ts new file mode 100644 index 00000000..33bd88ec --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.test.ts @@ -0,0 +1,27 @@ +import { Network } from '../constants/solana'; +import { addressToCaip10 } from './addressToCaip10'; + +describe('addressToCaip10', () => { + const testAddress = 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'; + + it('correctly formats mainnet addresses', () => { + const result = addressToCaip10(Network.Mainnet, testAddress); + expect(result).toBe( + `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:${testAddress}`, + ); + }); + + it('correctly formats devnet addresses', () => { + const result = addressToCaip10(Network.Devnet, testAddress); + expect(result).toBe( + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:${testAddress}`, + ); + }); + + it('correctly formats testnet addresses', () => { + const result = addressToCaip10(Network.Testnet, testAddress); + expect(result).toBe( + `solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z:${testAddress}`, + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.ts b/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.ts new file mode 100644 index 00000000..550b7eab --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/addressToCaip10.ts @@ -0,0 +1,15 @@ +import type { Caip10Address, Network } from '../constants/solana'; + +/** + * Converts a Solana address to a CAIP-10 address. + * + * @param scope - The network scope. + * @param address - The Solana address. + * @returns The CAIP-10 address. + */ +export function addressToCaip10( + scope: Network, + address: string, +): Caip10Address { + return `${scope}:${address}`; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.test.ts new file mode 100644 index 00000000..fcaceac9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.test.ts @@ -0,0 +1,165 @@ +/* eslint-disable no-script-url */ +import { buildUrl } from './buildUrl'; + +describe('buildUrl', () => { + it('combines base URL and path correctly', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + }); + expect(result).toBe('https://api.example.com/users'); + }); + + it('adds single query parameter', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('adds multiple query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123', name: 'john', role: 'admin' }, + }); + expect(result).toBe( + 'https://api.example.com/users?id=123&name=john&role=admin', + ); + }); + + it('handles path parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users/{id}', + pathParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users/123'); + }); + + it('encodes path parameters to prevent path traversal attacks', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/tokens/{assetId}', + pathParams: { assetId: 'solana:mainnet:token123' }, + }); + expect(result).toBe( + 'https://api.example.com/tokens/solana%3Amainnet%3Atoken123', + ); + }); + + it('handles trailing slash in base URL', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com/', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('throws error for invalid base URL', () => { + expect(() => + buildUrl({ + baseUrl: 'invalid-url', + path: '/users', + queryParams: {}, + }), + ).toThrow('Invalid URL format'); + }); + + // Security validation tests + it('prevents XSS in query parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/search', + queryParams: { + q: '', + callback: 'javascript:alert(1)', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('prevents path traversal attacks by encoding path parameters', () => { + 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'); + }); + + it('does not encode path parameters when encodePathParams is false', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/tokens/{assetId}', + pathParams: { assetId: 'solana:mainnet:token123' }, + encodePathParams: false, + }); + expect(result).toBe( + 'https://api.example.com/tokens/solana:mainnet:token123', + ); + }); + + it('encodes path parameters when encodePathParams is explicitly true', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/tokens/{assetId}', + pathParams: { assetId: 'solana:mainnet:token123' }, + encodePathParams: true, + }); + expect(result).toBe( + 'https://api.example.com/tokens/solana%3Amainnet%3Atoken123', + ); + }); + + it('maintains backward compatibility by encoding path parameters by default', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/tokens/{assetId}', + pathParams: { assetId: 'solana:mainnet:token123' }, + // encodePathParams not specified, it will default to true + }); + expect(result).toBe( + 'https://api.example.com/tokens/solana%3Amainnet%3Atoken123', + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.ts b/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.ts new file mode 100644 index 00000000..1708dcac --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/buildUrl.ts @@ -0,0 +1,73 @@ +import { assert } from '@metamask/superstruct'; + +import { UrlStruct } from '../validation/structs'; +import { sanitizeControlCharacters, sanitizeUri } from './sanitize'; + +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. + * + * 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, (_, 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 + + const url = new URL(cleanPath, sanitizedBaseUrl); + + Object.entries(queryParams ?? {}) + .filter(([_, value]) => value !== undefined) + .filter(([_, 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/solana-wallet-snap/src/core/utils/concurrency.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/concurrency.test.ts new file mode 100644 index 00000000..41fce73d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/concurrency.test.ts @@ -0,0 +1,238 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CancellablePromise } from './concurrency'; +import { + CancellationError, + withCancellable, + withoutConcurrency, +} from './concurrency'; + +describe('concurrency', () => { + describe('withCancellable', () => { + it('resolves normally when not cancelled', async () => { + const mockFn = jest.fn().mockResolvedValue('result'); + const cancellable = withCancellable(mockFn); + + const result = await cancellable(); + expect(result).toBe('result'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('rejects with CancellationError when cancelled', async () => { + const mockFn = jest.fn().mockImplementation(async () => { + return new Promise((resolve) => { + setTimeout(() => resolve('Hello'), 1000); + }); + }); + const cancellable = withCancellable(mockFn); + + const promise = cancellable(); + promise.cancel(); + + await expect(promise).rejects.toThrow(CancellationError); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('does not resolve after cancellation even if original promise resolves', async () => { + // Create a manually controlled promise that we can resolve explicitly + let resolveOriginal: (value: string) => void; + const originalPromise = new Promise((resolve) => { + resolveOriginal = resolve; + }); + + const mockFn = jest.fn().mockReturnValue(originalPromise); + const cancellable = withCancellable(mockFn); + + // Start the cancellable operation + const promise = cancellable(); + + // Set up a way to check if the promise resolves + let wasResolved = false; + promise + .then(() => { + wasResolved = true; + }) + .catch(() => { + // We expect it to be rejected with CancellationError + }); + + // Cancel it + promise.cancel(); + + // Now resolve the original promise + resolveOriginal!('result'); + + // Wait a bit to ensure any potential resolution would have happened + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify the promise was rejected, not resolved + expect(wasResolved).toBe(false); + await expect(promise).rejects.toThrow(CancellationError); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('supports multiple concurrent cancellable operations', async () => { + const delays = [100, 50, 150]; + const mockFn = jest + .fn() + .mockImplementation( + async (delay: number) => + new Promise((resolve) => + setTimeout(() => resolve(`finished ${delay}`), delay), + ), + ); + + const cancellable = withCancellable(mockFn); + + // Create cancellable promises directly + + const promises = delays.map((delay) => + cancellable(delay), + ) as CancellablePromise[]; + + // Cancel first and last operations + promises[0]!.cancel(); + promises[2]!.cancel(); + + const results = await Promise.allSettled(promises); + + expect(results[0]).toMatchObject({ + status: 'rejected', + reason: expect.any(CancellationError), + }); + expect(results[1]).toMatchObject({ + status: 'fulfilled', + value: 'finished 50', + }); + expect(results[2]).toMatchObject({ + status: 'rejected', + reason: expect.any(CancellationError), + }); + }); + + it('supports cancellation of the original function', async () => { + // Create a mock function that simulates a long-running operation + // and properly handles the AbortSignal + const mockFn = jest + .fn() + .mockImplementation(async (_param: string, signal?: AbortSignal) => { + // Check if already aborted + if (signal?.aborted) { + throw new CancellationError(); + } + + return new Promise((resolve, reject) => { + // Set up a timeout to simulate work + const timeout = setTimeout(() => resolve('completed'), 1000); + + // Set up cancellation handler + signal?.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new CancellationError('Operation aborted')); + }); + }); + }); + + const cancellable = withCancellable(mockFn); + + // Start the operation + const promise = cancellable('test-param'); + + // Cancel it + promise.cancel(); + + // Verify it was cancelled + await expect(promise).rejects.toThrow(CancellationError); + + // Verify the mock function was called with the signal + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn.mock.calls[0][0]).toBe('test-param'); + expect(mockFn.mock.calls[0][1]).toBeInstanceOf(AbortSignal); + + // Verify the signal was aborted + const passedSignal = mockFn.mock.calls[0][1] as AbortSignal; + expect(passedSignal.aborted).toBe(true); + }); + }); + + describe('withoutConcurrency', () => { + it('executes a task normally when there is no previous task', async () => { + // Create a mock function that returns a regular promise + const mockFn = jest.fn().mockResolvedValue('result'); + + // Wrap with withoutConcurrency + const nonConcurrentFn = withoutConcurrency(mockFn); + + // Execute the function + const result = await nonConcurrentFn(); + + // Verify the result + expect(result).toBe('result'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('cancels previous task when a new task is started', async () => { + // Create a manually controlled promise + let resolveFirst: (value: string) => void; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + // Create a mock function that returns our controlled promise for the first call + // and a regular resolved promise for the second call + const mockFn = jest + .fn() + .mockReturnValueOnce(firstPromise) + .mockResolvedValueOnce('second result'); + + // Create non-concurrent version from the promise-returning function + const nonConcurrentFn = withoutConcurrency(mockFn); + + // Start the first task (but don't resolve it yet) + const firstTask = nonConcurrentFn(); + + // Set up a way to check if the first task was cancelled + let firstWasCancelled = false; + firstTask.catch((error) => { + if (error instanceof CancellationError) { + firstWasCancelled = true; + } + }); + + // Start a second task before the first one completes + const secondResult = await nonConcurrentFn(); + + // Now resolve the first task + resolveFirst!('first result'); + + // Wait a bit to ensure any potential resolution would have happened + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify the first task was cancelled and the second task completed + expect(firstWasCancelled).toBe(true); + expect(secondResult).toBe('second result'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('allows a new task to start after previous task completes', async () => { + // Create a mock function + const mockFn = jest + .fn() + .mockResolvedValueOnce('first result') + .mockResolvedValueOnce('second result'); + + // Create non-concurrent version + const nonConcurrentFn = withoutConcurrency(mockFn); + + // Execute the first task and wait for it to complete + const firstResult = await nonConcurrentFn(); + + // Execute the second task + const secondResult = await nonConcurrentFn(); + + // Verify both tasks completed successfully + expect(firstResult).toBe('first result'); + expect(secondResult).toBe('second result'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/concurrency.ts b/merged-packages/solana-wallet-snap/src/core/utils/concurrency.ts new file mode 100644 index 00000000..ae32bf63 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/concurrency.ts @@ -0,0 +1,114 @@ +import logger from './logger'; + +export type CancellablePromise = Promise & { + cancel: () => void; +}; + +export class CancellationError extends Error { + constructor(message = 'Operation cancelled') { + super(message); + this.name = 'CancellationError'; + } +} + +/** + * Wraps a promise-returning function to make it cancellable. + * + * WARNING: Only the decorated function will be cancelled, not the original function. + * However, the AbortSignal is passed down to the original function, so it can use the signal to cancel its own operations. + * + * @example + * const processData = async (id: string) => { + * await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate work + * return { id, result: `Processed ${id}` }; + * }; + * + * const cancellableProcessData = withCancellable(processData); + * cancellableProcessData('123'); + * cancellableProcessData.cancel(); // This will cancel the previous process for '123' + * @example + * const fetchData = async (url: string, signal?: AbortSignal) => { + * const response = await fetch(url, { signal }); + * return response.json(); + * }; + * + * const cancellableFetch = withCancellable(fetchData); + * const promise = cancellableFetch('https://api.example.com/data'); + * promise.cancel(); // This will abort the fetch request + * @param fn - The function to make cancellable. + * @returns A cancellable promise. + */ +export const withCancellable = ( + fn: (...args: any[]) => Promise, +): ((...args: any[]) => CancellablePromise) => { + return (...args) => { + const abortController = new AbortController(); + + // Pass the AbortController's signal to the original function + // We'll add the signal as the last argument if it's not already provided + const argsWithSignal = [...args]; + if (!argsWithSignal.some((arg) => arg instanceof AbortSignal)) { + argsWithSignal.push(abortController.signal); + } + + const promise = Promise.race([ + fn(...argsWithSignal), + new Promise((_, reject) => { + abortController.signal.addEventListener('abort', () => { + reject(new CancellationError()); + }); + }), + ]) as CancellablePromise; + + promise.cancel = () => abortController.abort(); + return promise; + }; +}; + +/** + * Wraps a promise-returning function to prevent concurrent executions. + * Automatically converts the promise to a cancellable promise internally. + * + * @example + * const processData = async (id: string) => { + * await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate work + * return { id, result: `Processed ${id}` }; + * }; + * + * const nonConcurrentProcessData = withoutConcurrency(processData); + * + * // Only the latest call will complete, previous calls are cancelled + * nonConcurrentProcessData('123'); + * nonConcurrentProcessData('456'); // This will cancel the previous process for '123' + * @param fn - The function to wrap. + * @returns A cancellable promise. + */ +export function withoutConcurrency( + fn: (...args: any[]) => Promise, +): (...args: any[]) => Promise { + // Convert the function to return a cancellable promise + const cancellableFn = withCancellable(fn); + + let currentTask: CancellablePromise | null = null; + + return async (...args: any[]) => { + if (currentTask) { + if (typeof currentTask.cancel === 'function') { + const message = 'Cancelling previous task'; + logger.warn(message); + currentTask.cancel(); + } + } + + const newTask: CancellablePromise = cancellableFn(...args); + currentTask = newTask; + + try { + return await newTask; + } finally { + if (currentTask === newTask) { + currentTask = null; + } + } + }; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/conversion.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/conversion.test.ts new file mode 100644 index 00000000..f2da75b1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/conversion.test.ts @@ -0,0 +1,47 @@ +import BigNumber from 'bignumber.js'; + +import { lamportsToSol, solToLamports } from './conversion'; + +describe('Solana conversion utils', () => { + describe('lamportsToSol', () => { + it('correctly converts lamports to SOL', () => { + expect(lamportsToSol(1).toString()).toBe('0.000000001'); + expect(lamportsToSol(1).toFixed()).toBe('0.000000001'); + expect(lamportsToSol(1000000000).toString()).toBe('1'); + expect(lamportsToSol(500000000).toString()).toBe('0.5'); + expect(lamportsToSol(0).toString()).toBe('0'); + }); + + it('handles string inputs', () => { + expect(lamportsToSol('1000000000').toString()).toBe('1'); + }); + + it('handles BigNumber inputs', () => { + expect(lamportsToSol(new BigNumber('1000000000')).toString()).toBe('1'); + }); + + it('handles bigint inputs', () => { + expect(lamportsToSol(1000000000n).toString()).toBe('1'); + }); + }); + + describe('solToLamports', () => { + it('correctly converts SOL to lamports', () => { + expect(solToLamports(1).toString()).toBe('1000000000'); + expect(solToLamports(0.5).toString()).toBe('500000000'); + expect(solToLamports(0).toString()).toBe('0'); + }); + + it('handles string inputs', () => { + expect(solToLamports('1').toString()).toBe('1000000000'); + }); + + it('handles BigNumber inputs', () => { + expect(solToLamports(new BigNumber('1')).toString()).toBe('1000000000'); + }); + + it('handles bigint inputs', () => { + expect(solToLamports(BigInt(1)).toString()).toBe('1000000000'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/conversion.ts b/merged-packages/solana-wallet-snap/src/core/utils/conversion.ts new file mode 100644 index 00000000..17af35c9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/conversion.ts @@ -0,0 +1,29 @@ +import BigNumber from 'bignumber.js'; + +import { LAMPORTS_PER_SOL } from '../constants/solana'; + +/** + * Converts lamports to SOL. + * + * @param amountInLamports - The amount of lamports to convert. + * @returns The amount of SOL. + */ +export const lamportsToSol = ( + amountInLamports: string | number | bigint | BigNumber, +): BigNumber => { + return BigNumber(amountInLamports.toString()).dividedBy(LAMPORTS_PER_SOL); +}; + +/** + * Converts SOL to lamports. + * + * @param amountInSol - The amount of SOL to convert. + * @returns The amount of lamports. + */ +export const solToLamports = ( + amountInSol: string | number | bigint | BigNumber, +): BigNumber => { + return BigNumber(amountInSol.toString()) + .multipliedBy(LAMPORTS_PER_SOL) + .integerValue(BigNumber.ROUND_DOWN); +}; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.test.ts new file mode 100644 index 00000000..5976f109 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.test.ts @@ -0,0 +1,185 @@ +import type { SLIP10PathNode, SupportedCurve } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; + +import { + MOCK_SEED_PHRASE_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_2_PRIVATE_KEY_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_3_PRIVATE_KEY_BYTES, + MOCK_SOLANA_KEYRING_ACCOUNT_4_PRIVATE_KEY_BYTES, +} from '../test/mocks/solana-keyring-accounts'; +import { + deriveSolanaKeypair, + deriveSolanaKeypairFromCoinTypeNode, +} from './deriveSolanaKeypair'; +import { getBip32Entropy } from './getBip32Entropy'; + +/** + * Using the seed phrase: + * sugar interest animal afford dog imitate relief lizard width strategy embark midnight + * + * Yields the following root node from getBip32Deriver: + * ```json + * { + * "depth": 2, + * "masterFingerprint": 3974444335, + * "parentFingerprint": 2046425034, + * "index": 2147484149, + * "curve": "ed25519", + * "privateKey": "0x7acf6060833428c2196ce6e2c5ba5455394602814b9ec6b9bac453b357be7b24", + * "publicKey": "0x00389ed03449fbc42a3ec134609b664a50e7a78bad800bad1629113590bfc9af9b", + * "chainCode": "0x99d7cef35ae591a92eab31e0007f0199e3bea62d211a219526bf2ae06799886d" + * } + * ``` + * + * Returns the following addresses per index, using Solana's derivation path + * `m`, `44'`, `501'`, `${index}'`, `0'`: + * + * #0 - BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP + * #1 - FvS1p2dQnhWNrHyuVpJRU5mkYRkSTrubXHs4XrAn3PGo + * #2 - 27h6cm6S9ag5y4ASi1a1vbTSKEsQMjEdfvZ6atPjmbuD + * #3 - 3SYHDFbhoxuTCYCGRw7KSL8GbwUoByjtvyy7pUZeAhh8 + * #4 - FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK + * #5 - GN1DST49kafBmRDnKKwUbR6nJWoqzSJBPoHwcTXzgmkH + * + * And the private keys you see in the first test case below. + */ + +jest.mock('./getBip32Entropy'); +jest.mock('./logger'); + +describe('deriveSolanaKeypair', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getBip32Entropy as jest.Mock).mockImplementation( + async ({ path, curve }: { path: string[]; curve: SupportedCurve }) => { + return await SLIP10Node.fromDerivationPath({ + derivationPath: [ + MOCK_SEED_PHRASE_BYTES, + ...path.slice(1).map((node) => `slip10:${node}` as SLIP10PathNode), + ], + curve, + }); + }, + ); + }); + + describe('when the derivation path and BIP32 entropy are valid', () => { + it('successfully derives Solana private keys', async () => { + const { privateKeyBytes: firstPrivateKey } = await deriveSolanaKeypair({ + derivationPath: `m/44'/501'/0'/0'`, + }); + const { privateKeyBytes: secondPrivateKey } = await deriveSolanaKeypair({ + derivationPath: `m/44'/501'/1'/0'`, + }); + const { privateKeyBytes: thirdPrivateKey } = await deriveSolanaKeypair({ + derivationPath: `m/44'/501'/2'/0'`, + }); + const { privateKeyBytes: fourthPrivateKey } = await deriveSolanaKeypair({ + derivationPath: `m/44'/501'/3'/0'`, + }); + const { privateKeyBytes: fifthPrivateKey } = await deriveSolanaKeypair({ + derivationPath: `m/44'/501'/4'/0'`, + }); + + expect(firstPrivateKey).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + ); + expect(secondPrivateKey).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES, + ); + expect(thirdPrivateKey).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_2_PRIVATE_KEY_BYTES, + ); + expect(fourthPrivateKey).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_3_PRIVATE_KEY_BYTES, + ); + expect(fifthPrivateKey).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_4_PRIVATE_KEY_BYTES, + ); + }); + }); + + describe('when the derivation path is not valid', () => { + it('throws an error', async () => { + await expect( + deriveSolanaKeypair({ derivationPath: `m/44'/1'/0'` }), + ).rejects.toThrow( + "Expected a string matching `/^m\\/44'\\/501'/` but received \"m/44'/1'/0'\"", + ); + }); + }); + + describe('when the BIP32 entropy is invalid', () => { + it('throws an error', async () => { + (getBip32Entropy as jest.Mock).mockResolvedValue({} as any); + + await expect( + deriveSolanaKeypair({ derivationPath: `m/44'/501'/0'/0'` }), + ).rejects.toThrow('Unable to derive private key'); + }); + }); + + describe('when we are unable to get BIP32 entropy', () => { + it('throws an error', async () => { + const errorMessage = 'Failed to get entropy'; + (getBip32Entropy as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect( + deriveSolanaKeypair({ derivationPath: `m/44'/501'/0'/0'` }), + ).rejects.toThrow(errorMessage); + }); + }); +}); + +describe('deriveSolanaKeypairFromCoinTypeNode', () => { + const getCoinTypeNode = async () => { + return await SLIP10Node.fromDerivationPath({ + derivationPath: [MOCK_SEED_PHRASE_BYTES, `slip10:44'`, `slip10:501'`], + curve: 'ed25519', + }); + }; + + it('derives the same private keys as deriveSolanaKeypair for indices 0-4', async () => { + const coinTypeNode = await getCoinTypeNode(); + + const results = await Promise.all( + [0, 1, 2, 3, 4].map(async (accountIndex) => { + return deriveSolanaKeypairFromCoinTypeNode({ + coinTypeNode, + accountIndex, + }); + }), + ); + + expect(results[0]?.privateKeyBytes).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, + ); + expect(results[1]?.privateKeyBytes).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_1_PRIVATE_KEY_BYTES, + ); + expect(results[2]?.privateKeyBytes).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_2_PRIVATE_KEY_BYTES, + ); + expect(results[3]?.privateKeyBytes).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_3_PRIVATE_KEY_BYTES, + ); + expect(results[4]?.privateKeyBytes).toStrictEqual( + MOCK_SOLANA_KEYRING_ACCOUNT_4_PRIVATE_KEY_BYTES, + ); + }); + + it('throws an error when the derived node has no private key', async () => { + const coinTypeNode = { + derive: jest.fn().mockResolvedValue({ + privateKeyBytes: undefined, + publicKeyBytes: undefined, + }), + } as unknown as SLIP10Node; + + await expect( + deriveSolanaKeypairFromCoinTypeNode({ coinTypeNode, accountIndex: 0 }), + ).rejects.toThrow('Unable to derive private key'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.ts b/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.ts new file mode 100644 index 00000000..d66c2ff7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/deriveSolanaKeypair.ts @@ -0,0 +1,101 @@ +import type { SLIP10Node } from '@metamask/key-tree'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import { assert } from '@metamask/superstruct'; +import { hexToBytes } from '@metamask/utils'; + +import { DerivationPathStruct } from '../validation/structs'; +import { getBip32Entropy } from './getBip32Entropy'; +import logger from './logger'; + +/** + * Elliptic curve + * + * See: https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/ + */ +const CURVE = 'ed25519' as const; + +/** + * Derives a Solana private and public key from a given index using BIP44 derivation path. + * The derivation path follows Phantom wallet's standard: m/44'/501'/index'/0'. + * + * @param params - The parameters for the Solana key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.derivationPath - The derivation path to use for key derivation. + * @returns A Promise that resolves to a Uint8Array of the private key. + * @throws {Error} If unable to derive private key or if derivation fails. + * @example + * ```typescript + * const { privateKeyBytes, publicKeyBytes } = await deriveSolanaPrivateKey(0); + * ``` + * @see {@link https://help.phantom.app/hc/en-us/articles/12988493966227-What-derivation-paths-does-Phantom-wallet-support} Phantom wallet derivation paths + * @see {@link https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki} BIP44 specification + * @see {@link https://github.com/satoshilabs/slips/blob/master/slip-0044.md} SLIP-0044 for coin types. + */ +export async function deriveSolanaKeypair({ + entropySource, + derivationPath, +}: { + entropySource?: EntropySourceId | undefined; + derivationPath: string; +}): Promise<{ privateKeyBytes: Uint8Array; publicKeyBytes: Uint8Array }> { + logger.log({ derivationPath }, 'Generating solana wallet'); + + assert(derivationPath, DerivationPathStruct); + + const path = derivationPath.split('/'); + + try { + const node = await getBip32Entropy({ + entropySource, + path, + curve: CURVE, + }); + + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key'); + } + + return { + privateKeyBytes: hexToBytes(node.privateKey), + publicKeyBytes: hexToBytes(node.publicKey), + }; + } catch (error: any) { + // Log the error for debugging purposes, but rethrow it to be handled/tracked by the caller. + + logger.error({ error }, 'Error deriving keypair'); + throw new Error(error); + } +} + +/** + * Derives a Solana keypair from a pre-computed coin-type node (m/44'/501'), + * avoiding a snap_getBip32Entropy call per account. + * + * @param params - The parameters for key derivation. + * @param params.coinTypeNode - The SLIP10 node at m/44'/501'. + * @param params.accountIndex - The BIP-44 account index to derive. + * @returns A Promise that resolves to the private and public key bytes. + */ +export async function deriveSolanaKeypairFromCoinTypeNode({ + coinTypeNode, + accountIndex, +}: { + coinTypeNode: SLIP10Node; + accountIndex: number; +}): Promise<{ privateKeyBytes: Uint8Array; publicKeyBytes: Uint8Array }> { + // We use m/44'/501'/${accountIndex}'/0' as the derivation path. So now need to derive + // the account index + the change index (0) from the coin type node. + const derived = await coinTypeNode.derive([ + `slip10:${accountIndex}'`, + `slip10:0'`, + ]); + + if (!derived.privateKeyBytes || !derived.publicKeyBytes) { + throw new Error('Unable to derive private key'); + } + + return { + privateKeyBytes: derived.privateKeyBytes, + publicKeyBytes: derived.publicKeyBytes, + }; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.test.ts new file mode 100644 index 00000000..000f5a92 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.test.ts @@ -0,0 +1,63 @@ +import { diffArrays } from './diffArrays'; + +describe('diffArrays', () => { + it('returns added and deleted elements when there are differences', () => { + const array1 = [1, 2, 3]; + const array2 = [2, 3, 4]; + const result = diffArrays(array1, array2); + + expect(result).toStrictEqual({ + added: [4], + deleted: [1], + hasDiff: true, + }); + }); + + it('returns empty arrays when there are no differences', () => { + const array1 = [1, 2, 3]; + const array2 = [1, 2, 3]; + const result = diffArrays(array1, array2); + + expect(result).toStrictEqual({ + added: [], + deleted: [], + hasDiff: false, + }); + }); + + it('returns all elements as added when the first array is empty', () => { + const array1: number[] = []; + const array2 = [1, 2, 3]; + const result = diffArrays(array1, array2); + + expect(result).toStrictEqual({ + added: [1, 2, 3], + deleted: [], + hasDiff: true, + }); + }); + + it('returns all elements as deleted when the second array is empty', () => { + const array1 = [1, 2, 3]; + const array2: number[] = []; + const result = diffArrays(array1, array2); + + expect(result).toStrictEqual({ + added: [], + deleted: [1, 2, 3], + hasDiff: true, + }); + }); + + it('handles arrays with different types', () => { + const array1 = ['a', 'b', 'c']; + const array2 = ['b', 'c', 'd']; + const result = diffArrays(array1, array2); + + expect(result).toStrictEqual({ + added: ['d'], + deleted: ['a'], + hasDiff: true, + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.ts b/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.ts new file mode 100644 index 00000000..b317a663 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/diffArrays.ts @@ -0,0 +1,26 @@ +type DiffResult = { + added: Type[]; + deleted: Type[]; + hasDiff: boolean; +}; + +/** + * Computes the difference between two arrays. + * + * @param array1 - The first array. + * @param array2 - The second array. + * @returns An object containing the added and deleted elements. + */ +export function diffArrays( + array1: Type[], + array2: Type[], +): DiffResult { + const added = array2.filter((item) => !array1.includes(item)); + const deleted = array1.filter((item) => !array2.includes(item)); + + return { + hasDiff: added.length > 0 || deleted.length > 0, + added, + deleted, + }; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.test.ts new file mode 100644 index 00000000..ba28dedb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.test.ts @@ -0,0 +1,87 @@ +import { diffObjects } from './diffObjects'; + +describe('diffObjects', () => { + it('detects added properties', () => { + const object1 = { a: 1 }; + const object2 = { a: 1, b: 2 }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: { b: 2 }, + deleted: {}, + changed: {}, + hasDiff: true, + }); + }); + + it('detects deleted properties', () => { + const object1 = { a: 1, b: 2 }; + const object2 = { a: 1 }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: {}, + deleted: { b: 2 }, + changed: {}, + hasDiff: true, + }); + }); + + it('detects nested added properties', () => { + const object1 = { a: 1, b: { c: 3 } }; + const object2 = { a: 1, b: { c: 3, d: 4 } }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: { 'b.d': 4 }, + deleted: {}, + changed: {}, + hasDiff: true, + }); + }); + + it('detects nested deleted properties', () => { + const object1 = { a: 1, b: { c: 3, d: 4 } }; + const object2 = { a: 1, b: { c: 3 } }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: {}, + deleted: { 'b.d': 4 }, + changed: {}, + hasDiff: true, + }); + }); + + it('detects changed properties', () => { + const object1 = { a: 1, b: 2 }; + const object2 = { a: 1, b: 3 }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: {}, + deleted: {}, + changed: { b: 3 }, + hasDiff: true, + }); + }); + + it('detects no differences', () => { + const object1 = { a: 1, b: 2 }; + const object2 = { a: 1, b: 2 }; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: {}, + deleted: {}, + changed: {}, + hasDiff: false, + }); + }); + + it('handles empty objects', () => { + const object1 = {}; + const object2 = {}; + const result = diffObjects(object1, object2); + expect(result).toStrictEqual({ + added: {}, + deleted: {}, + changed: {}, + hasDiff: false, + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.ts b/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.ts new file mode 100644 index 00000000..900cd6e8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/diffObjects.ts @@ -0,0 +1,66 @@ +type DiffResult = { + added: Record; + deleted: Record; + changed: Record; + hasDiff: boolean; +}; + +/** + * Computes the differences between two objects. + * + * @param object1 - The first object to compare. + * @param object2 - The second object to compare. + * @returns The differences between the two objects. + */ +export function diffObjects( + object1: Record, + object2: Record, +): DiffResult { + const diffs: DiffResult = { + added: {}, + deleted: {}, + changed: {}, + hasDiff: false, + }; + + const findDiffs = ( + o1: Record, + o2: Record, + path = '', + ) => { + for (const key in o1) { + if (Object.prototype.hasOwnProperty.call(o1, key)) { + const newPath = path ? `${path}.${key}` : key; + if (!Object.prototype.hasOwnProperty.call(o2, key)) { + diffs.deleted[newPath] = o1[key]; + diffs.hasDiff = true; + } else if ( + typeof o1[key] === 'object' && + o1[key] !== null && + typeof o2[key] === 'object' && + o2[key] !== null + ) { + findDiffs(o1[key], o2[key], newPath); + } else if (o1[key] !== o2[key]) { + const topKey = newPath.split('.')[0]; + diffs.changed[topKey ?? key] = object2[topKey ?? key]; + diffs.hasDiff = true; + } + } + } + + for (const key in o2) { + if ( + Object.prototype.hasOwnProperty.call(o2, key) && + !Object.prototype.hasOwnProperty.call(o1, key) + ) { + const newPath = path ? `${path}.${key}` : key; + diffs.added[newPath] = o2[key]; + diffs.hasDiff = true; + } + } + }; + + findDiffs(object1, object2); + return diffs; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/errors.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/errors.test.ts new file mode 100644 index 00000000..cd68623a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/errors.test.ts @@ -0,0 +1,262 @@ +import { expect } from '@jest/globals'; +import { SnapError, UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { trackError, withCatchAndThrowSnapError } from './errors'; +import logger from './logger'; + +// Mock the logger to avoid actual console output during tests +jest.mock('./logger', () => ({ + error: jest.fn(), + warn: jest.fn(), +})); + +const setupTest = () => { + jest.clearAllMocks(); + + const mockSnapRequest = jest.fn(); + (globalThis as any).snap = { + request: mockSnapRequest, + }; + + return { mockSnapRequest, mockLogger: logger as jest.Mocked }; +}; + +describe('errors', () => { + describe('trackError', () => { + it('does not throw if error tracking fails', async () => { + const { mockLogger, mockSnapRequest } = setupTest(); + + const originalError = new Error('Test error'); + const trackingError = new Error('Tracking failed'); + mockSnapRequest.mockRejectedValue(trackingError); + + expect(await trackError(originalError)).toBeUndefined(); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: originalError.message, + }), + }, + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { + error: trackingError, + }, + 'Failed to track error', + ); + }); + + it('tracks errors', async () => { + const { mockLogger, mockSnapRequest } = setupTest(); + + const originalError = new Error('Test error'); + mockSnapRequest.mockResolvedValue('tracked-error-id'); + + expect(await trackError(originalError)).toBe('tracked-error-id'); + + expect(mockSnapRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: originalError.message, + }), + }, + }); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('handle', () => { + it('returns the result when the function succeeds', async () => { + const { mockLogger } = setupTest(); + + 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 { mockLogger } = setupTest(); + + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('logs errors with the correct scope and error details', async () => { + const { mockLogger } = setupTest(); + + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + try { + await withCatchAndThrowSnapError(mockFn); + } catch (error) { + // 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]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles non-Error objects and converts them to SnapError', async () => { + const { mockLogger } = setupTest(); + + 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]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles null and undefined errors', async () => { + const { mockLogger } = setupTest(); + + 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 { mockLogger } = setupTest(); + + 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 { mockLogger } = setupTest(); + + 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]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + expect(loggedError?.message).toBe(errorTypes[i]?.message); + } + }); + + it('includes error stack trace in the logged error', async () => { + const { mockLogger } = setupTest(); + + 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 (error) { + // Expected to throw + } + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(SnapError) }, + expect.stringContaining('[SnapError]'), + ); + }); + + it('handles functions that throw promises', async () => { + const { mockLogger } = setupTest(); + + 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); + }); + + it('does not track errors when shouldTrackError returns false', async () => { + const { mockLogger, mockSnapRequest } = setupTest(); + + const originalError = new UserRejectedRequestError(); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(mockSnapRequest).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/errors.ts b/merged-packages/solana-wallet-snap/src/core/utils/errors.ts new file mode 100644 index 00000000..c9810aed --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/errors.ts @@ -0,0 +1,95 @@ +import { + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + ChainDisconnectedError, + TransactionRejected, + DisconnectedError, + InternalError, + UnauthorizedError, + UnsupportedMethodError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + SnapError, + MethodNotSupportedError, + UserRejectedRequestError, + getJsonError, +} from '@metamask/snaps-sdk'; + +import logger from './logger'; + +/** + * 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): boolean { + const errors = [ + SnapError, + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + ]; + return errors.some((errType) => error instanceof errType); +} + +export const trackError = async ( + error: unknown, +): Promise => { + try { + return await snap.request({ + method: 'snap_trackError', + params: { + error: getJsonError(error), + }, + }); + } catch (trackingError) { + logger.warn({ error: trackingError }, 'Failed to track error'); + return undefined; + } +}; + +const shouldTrackError = (error: Error): boolean => { + return !(error instanceof UserRejectedRequestError); +}; + +export const withCatchAndThrowSnapError = async ( + fn: () => Promise, +): Promise => { + try { + return await fn(); + } catch (errorInstance: any) { + if (shouldTrackError(errorInstance)) { + await trackError(errorInstance); + } + + const error = isSnapRpcError(errorInstance) + ? errorInstance + : new SnapError(errorInstance); + + logger.error( + { error }, + `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, + ); + + throw error; + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.test.ts new file mode 100644 index 00000000..8570b229 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.test.ts @@ -0,0 +1,82 @@ +import BigNumber from 'bignumber.js'; + +import { formatCrypto } from './formatCrypto'; + +describe('formatCrypto', () => { + const locale = 'en'; + const symbol = 'SOL'; + + describe('covers zero values', () => { + it('returns "0" for zero amount', () => { + expect(formatCrypto(new BigNumber(0), symbol, locale)).toBe('0 SOL'); + expect(formatCrypto('0', symbol, locale)).toBe('0 SOL'); + expect(formatCrypto(0, symbol, locale)).toBe('0 SOL'); + }); + }); + + describe('covers small values', () => { + it('formats very small numbers with < prefix', () => { + expect(formatCrypto(0.0000001, symbol, locale)).toBe('<0.000001 SOL'); + expect(formatCrypto('0.0000001', symbol, locale)).toBe('<0.000001 SOL'); + expect(formatCrypto(new BigNumber('0.0000001'), symbol, locale)).toBe( + '<0.000001 SOL', + ); + }); + + it('formats numbers less than 1 with maximum significant digits', () => { + expect(formatCrypto(0.123456, symbol, locale)).toBe('0.123 SOL'); + expect(formatCrypto('0.123456', symbol, locale)).toBe('0.123 SOL'); + expect(formatCrypto(new BigNumber('0.123456'), symbol, locale)).toBe( + '0.123 SOL', + ); + }); + }); + + describe('covers regular values', () => { + it('formats numbers with appropriate decimal places based on integer length', () => { + // 1 digit integer (shows up to 3 decimal places) + expect(formatCrypto(1.23456789, symbol, locale)).toBe('1.235 SOL'); + + // 2 digits integer (shows up to 2 decimal places) + expect(formatCrypto(12.3456789, symbol, locale)).toBe('12.35 SOL'); + + // 3 digits integer (shows up to 1 decimal place) + expect(formatCrypto(123.456789, symbol, locale)).toBe('123.5 SOL'); + + // 4+ digits integer (shows no decimal places) + expect(formatCrypto(1234.56789, symbol, locale)).toBe('1,235 SOL'); + }); + + it('formats numbers with commas for thousands', () => { + expect(formatCrypto(1234567.89, symbol, locale)).toBe('1,234,568 SOL'); + }); + }); + + describe('covers negative values', () => { + it('handles negative numbers correctly', () => { + expect(formatCrypto(-0.0000001, symbol, locale)).toBe('<0.000001 SOL'); + }); + }); + + describe('covers error handling', () => { + it('handles invalid inputs gracefully', () => { + expect(formatCrypto('invalid', symbol, locale)).toBe('0 SOL'); + expect(formatCrypto(NaN, symbol, locale)).toBe('0 SOL'); + }); + }); + + describe('handles different locales correctly', () => { + it('formats numbers according to locale', () => { + expect(formatCrypto(1234.5678, symbol, 'de-DE')).toBe('1.235 SOL'); + // Heads up! That is not a normal space, but a non-breaking space. + expect(formatCrypto(1234.5678, symbol, 'fr-FR')).toBe('1 235 SOL'); + }); + }); + + describe('handles different symbols', () => { + it('formats with different symbols', () => { + expect(formatCrypto(1234.5678, 'ETH', locale)).toBe('1,235 ETH'); + expect(formatCrypto(1234.5678, '', locale)).toBe('1,235 '); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.ts new file mode 100644 index 00000000..5b1e3f34 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatCrypto.ts @@ -0,0 +1,20 @@ +import type BigNumber from 'bignumber.js'; + +import { formatCryptoBalance } from './formatCryptoBalance'; + +/** + * Formats a token amount with commas and a specified symbol. + * + * @param amount - The amount of tokens as a BigNumber. + * @param symbol - The symbol of the token. + * @param locale - The locale to use for number formatting. + * @returns The formatted token amount with symbol. + */ +export function formatCrypto( + amount: number | string | BigNumber, + symbol: string, + locale: string, +) { + const cryptoBalance = formatCryptoBalance(amount, locale); + return `${cryptoBalance} ${symbol}`; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.test.ts new file mode 100644 index 00000000..6dbe821a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.test.ts @@ -0,0 +1,66 @@ +import BigNumber from 'bignumber.js'; + +import { formatCryptoBalance } from './formatCryptoBalance'; + +describe('formatCryptoBalance', () => { + const locale = 'en'; + + describe('covers zero values', () => { + it('returns "0" for zero amount', () => { + expect(formatCryptoBalance(new BigNumber(0), locale)).toBe('0'); + expect(formatCryptoBalance('0', locale)).toBe('0'); + expect(formatCryptoBalance(0, locale)).toBe('0'); + }); + }); + + describe('covers small values', () => { + it('formats very small numbers with < prefix', () => { + expect(formatCryptoBalance(0.0000001, locale)).toBe('<0.000001'); + expect(formatCryptoBalance('0.0000001', locale)).toBe('<0.000001'); + expect(formatCryptoBalance(new BigNumber('0.0000001'), locale)).toBe( + '<0.000001', + ); + }); + + it('formats numbers less than 1 with maximum significant digits', () => { + expect(formatCryptoBalance(0.123456, locale)).toBe('0.123'); + expect(formatCryptoBalance('0.123456', locale)).toBe('0.123'); + expect(formatCryptoBalance(new BigNumber('0.123456'), locale)).toBe( + '0.123', + ); + }); + }); + + describe('covers regular values', () => { + it('formats numbers with appropriate decimal places based on integer length', () => { + // 1 digit integer (shows up to 3 decimal places) + expect(formatCryptoBalance(1.23456789, locale)).toBe('1.235'); + + // 2 digits integer (shows up to 2 decimal places) + expect(formatCryptoBalance(12.3456789, locale)).toBe('12.35'); + + // 3 digits integer (shows up to 1 decimal place) + expect(formatCryptoBalance(123.456789, locale)).toBe('123.5'); + + // 4+ digits integer (shows no decimal places) + expect(formatCryptoBalance(1234.56789, locale)).toBe('1,235'); + }); + + it('formats numbers with commas for thousands', () => { + expect(formatCryptoBalance(1234567.89, locale)).toBe('1,234,568'); + }); + }); + + describe('covers negative values', () => { + it('handles negative numbers correctly', () => { + expect(formatCryptoBalance(-0.0000001, locale)).toBe('<0.000001'); + }); + }); + + describe('covers error handling', () => { + it('handles invalid inputs gracefully', () => { + expect(formatCryptoBalance('invalid', locale)).toBe('0'); + expect(formatCryptoBalance(NaN, locale)).toBe('0'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.ts new file mode 100644 index 00000000..829da5b9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatCryptoBalance.ts @@ -0,0 +1,94 @@ +import BigNumber from 'bignumber.js'; + +import logger from './logger'; + +const MIN_AMOUNT = 0.000001; +const MAX_SIGNIFICANT_DECIMAL_PLACES = 3; +const ZERO_DISPLAY = '0'; +const DEFAULT_PRECISION = new BigNumber(MIN_AMOUNT).decimalPlaces(); + +/** + * Formats a number with maximum precision, preserving all decimal places. + * + * @param locale - The locale to use for number formatting. + * @param value - The numeric value to format. + * @returns The formatted number as a string. + */ +function formatAmount(locale: string, value: number | BigNumber): string { + const bigNumberValue = new BigNumber(value); + const numberOfDecimals = bigNumberValue.decimalPlaces(); + const formattedValue = bigNumberValue.toFixed(numberOfDecimals ?? 0); + const [localeCode] = locale.split('_'); + + const [integerPart, fractionalPart] = formattedValue.split('.'); + const formattedIntegerPart = new Intl.NumberFormat(localeCode).format( + integerPart as unknown as number, + ); + + return fractionalPart + ? `${formattedIntegerPart}.${fractionalPart}` + : formattedIntegerPart; +} + +/** + * Formats a token amount with commas and a specified symbol. + * + * @param amount - The amount of tokens as a BigNumber. + * @param locale - The locale to use for number formatting. + * @returns The formatted token amount with symbol. + */ +export function formatCryptoBalance( + amount: number | string | BigNumber, + locale: string, +) { + const [localeCode] = locale.split('_'); + + try { + const bignumberAmount = new BigNumber(amount); + + if (bignumberAmount.isNaN()) { + return ZERO_DISPLAY; + } + + if (bignumberAmount.isZero()) { + return ZERO_DISPLAY; + } + + if (bignumberAmount.abs().lt(MIN_AMOUNT)) { + return `<${formatAmount(locale, MIN_AMOUNT)}`; + } + + if (bignumberAmount.abs().lt(1)) { + return new Intl.NumberFormat(localeCode, { + maximumSignificantDigits: MAX_SIGNIFICANT_DECIMAL_PLACES, + }).format(Number(bignumberAmount.toFixed(DEFAULT_PRECISION ?? 0))); + } + + // Preserve all digits left of the decimal point. + // Cap the digits right of the decimal point: The more digits present + // on the left side of the decimal point, the less decimal places + // we show on the right side. + const digitsLeftOfDecimal = bignumberAmount + .abs() + .integerValue() + .toString().length; + const maximumFractionDigits = Math.max( + 0, + MAX_SIGNIFICANT_DECIMAL_PLACES - digitsLeftOfDecimal + 1, + ); + + const formattedAmount = new Intl.NumberFormat(locale, { + maximumFractionDigits, + }).format( + // string is valid parameter for format function + // for some reason it gives TS issue + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format#number + bignumberAmount.toFixed(maximumFractionDigits) as unknown as number, + ); + + return formattedAmount; + } catch (error) { + logger.error(error); + return ZERO_DISPLAY; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.test.ts new file mode 100644 index 00000000..3147d2d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.test.ts @@ -0,0 +1,41 @@ +import { formatFiat } from './formatFiat'; + +describe('formatFiat', () => { + it('formats positive amounts correctly', () => { + expect(formatFiat('1234.56', 'usd', 'en-US')).toBe('$1,234.56'); + }); + + it('formats negative amounts correctly', () => { + expect(formatFiat('-1234.56', 'usd', 'en-US')).toBe('-$1,234.56'); + }); + + it('formats large amounts correctly', () => { + expect(formatFiat('1234567890.12', 'usd', 'en-US')).toBe( + '$1,234,567,890.12', + ); + }); + + it('formats small amounts correctly', () => { + expect(formatFiat('0.99', 'usd', 'en-US')).toBe('$0.99'); + }); + + it('formats zero correctly', () => { + expect(formatFiat('0', 'usd', 'en-US')).toBe('$0.00'); + }); + + it('handles amounts with no decimal part', () => { + expect(formatFiat('1000', 'usd', 'en-US')).toBe('$1,000.00'); + }); + + it('handles amounts with more than two decimal places', () => { + expect(formatFiat('1234.5678', 'usd', 'en-US')).toBe('$1,234.57'); + }); + + it('handles negative amounts with more than two decimal places', () => { + expect(formatFiat('-1234.5678', 'usd', 'en-US')).toBe('-$1,234.57'); + }); + + it('handles different currency', () => { + expect(formatFiat('1234.5678', 'eur', 'en-US')).toBe('€1,234.57'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.ts new file mode 100644 index 00000000..8c2b7582 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatFiat.ts @@ -0,0 +1,26 @@ +import BigNumber from 'bignumber.js'; + +/** + * Formats a number as currency. + * + * @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. + */ +export function formatFiat( + amount: string, + currency: string, + locale: string, +): string { + const bigAmount = new BigNumber(amount); + const amountNumber = bigAmount.toNumber(); + const [localeCode] = locale.split('_'); + + return amountNumber.toLocaleString(localeCode, { + style: 'currency', + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.test.ts new file mode 100644 index 00000000..af1c9db2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.test.ts @@ -0,0 +1,35 @@ +import BigNumber from 'bignumber.js'; + +import { formatFiatBalance } from './formatFiatBalance'; + +describe('formatFiatBalance', () => { + it('should format number inputs to 2 decimal places', () => { + expect(formatFiatBalance(123.456)).toBe('123.46'); + expect(formatFiatBalance(123.45)).toBe('123.45'); + expect(formatFiatBalance(123)).toBe('123.00'); + }); + + it('should format string inputs to 2 decimal places', () => { + expect(formatFiatBalance('123.456')).toBe('123.46'); + expect(formatFiatBalance('123.45')).toBe('123.45'); + expect(formatFiatBalance('123')).toBe('123.00'); + }); + + it('should format BigNumber inputs to 2 decimal places', () => { + expect(formatFiatBalance(new BigNumber('123.456'))).toBe('123.46'); + expect(formatFiatBalance(new BigNumber('123.45'))).toBe('123.45'); + expect(formatFiatBalance(new BigNumber('123'))).toBe('123.00'); + }); + + it('should handle zero values', () => { + expect(formatFiatBalance(0)).toBe('0.00'); + expect(formatFiatBalance('0')).toBe('0.00'); + expect(formatFiatBalance(new BigNumber(0))).toBe('0.00'); + }); + + it('should handle negative values', () => { + expect(formatFiatBalance(-123.456)).toBe('-123.46'); + expect(formatFiatBalance('-123.456')).toBe('-123.46'); + expect(formatFiatBalance(new BigNumber(-123.456))).toBe('-123.46'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.ts b/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.ts new file mode 100644 index 00000000..cccbc9b5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/formatFiatBalance.ts @@ -0,0 +1,14 @@ +import BigNumber from 'bignumber.js'; + +/** + * Formats a number to 2 decimal places. + * + * @param amount - The amount of money. + * @returns The formatted string. + */ +export function formatFiatBalance(amount: number | string | BigNumber) { + const bigAmount = new BigNumber(amount); + const amountNumber = bigAmount.toNumber().toFixed(2); + + return amountNumber; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnit.ts b/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnit.ts new file mode 100644 index 00000000..a86d3a4e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnit.ts @@ -0,0 +1,16 @@ +import BigNumber from 'bignumber.js'; + +/** + * Converts raw token units to human-readable format. + * + * @param amount - The amount in raw units. + * @param decimals - The number of decimals the token has. + * @returns The amount in human-readable format. + */ +export function fromTokenUnits( + amount: string | number | bigint | BigNumber, + decimals: number, +): string { + const bn = new BigNumber(amount.toString()); + return bn.div(10 ** decimals).toFixed(); // Use toFixed to avoid automatic scientific notation like 1e-18 +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnits.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnits.test.ts new file mode 100644 index 00000000..948597a7 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/fromTokenUnits.test.ts @@ -0,0 +1,42 @@ +import BigNumber from 'bignumber.js'; + +import { fromTokenUnits } from './fromTokenUnit'; + +describe('fromTokenUnits', () => { + it('converts string amount correctly', () => { + expect(fromTokenUnits('1000000000000000000', 18)).toBe('1'); + expect(fromTokenUnits('1234560000000000000', 18)).toBe('1.23456'); + }); + + it('converts number amount correctly', () => { + expect(fromTokenUnits(1000000, 6)).toBe('1'); + expect(fromTokenUnits(1234560, 6)).toBe('1.23456'); + }); + + it('converts BigNumber amount correctly', () => { + const amount = new BigNumber('1000000000000000000'); + expect(fromTokenUnits(amount, 18)).toBe('1'); + }); + + it('converts bigint amount correctly', () => { + const amount = BigInt('1000000000000000000'); + expect(fromTokenUnits(amount, 18)).toBe('1'); + }); + + it('handles zero correctly', () => { + expect(fromTokenUnits('0', 18)).toBe('0'); + expect(fromTokenUnits(0, 18)).toBe('0'); + expect(fromTokenUnits(new BigNumber(0), 18)).toBe('0'); + }); + + it('handles different decimal places', () => { + expect(fromTokenUnits('1000000', 6)).toBe('1'); + expect(fromTokenUnits('100', 2)).toBe('1'); + expect(fromTokenUnits('1000', 3)).toBe('1'); + }); + + it('handles very small numbers', () => { + expect(fromTokenUnits('1', 18)).toBe('0.000000000000000001'); + expect(fromTokenUnits('10', 18)).toBe('0.00000000000000001'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.test.ts new file mode 100644 index 00000000..f7366835 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.test.ts @@ -0,0 +1,27 @@ +import { getAccountIdFromAddress } from './getAccountIdFromAddress'; + +describe('getAccountIdFromAddress', () => { + it('should return the account ID if the address is found', () => { + const accounts = [ + { id: '1', address: '123' }, + { id: '2', address: '456' }, + ]; + const address = '123'; + + const result = getAccountIdFromAddress(accounts as any, address); + + expect(result).toBe('1'); + }); + + it('should return undefined if the address is not found', () => { + const accounts = [ + { id: '1', address: '123' }, + { id: '2', address: '456' }, + ]; + const address = '789'; + + const result = getAccountIdFromAddress(accounts as any, address); + + expect(result).toBeUndefined(); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.ts b/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.ts new file mode 100644 index 00000000..3b9bfe74 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.ts @@ -0,0 +1,15 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +/** + * Finds the account ID from the address. + * + * @param accounts - The accounts to search through. + * @param address - The address to find the account ID for. + * @returns The account ID if found, otherwise undefined. + */ +export function getAccountIdFromAddress( + accounts: KeyringAccount[], + address: string, +): string | undefined { + return accounts.find((account) => account.address === address)?.id; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.test.ts new file mode 100644 index 00000000..33aad4b5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-restricted-globals */ +import { getBip32Entropy } from './getBip32Entropy'; + +describe('getBip32Entropy', () => { + beforeEach(() => { + jest.resetAllMocks(); + Object.defineProperty(global, 'snap', { + value: { + request: jest.fn().mockResolvedValueOnce({ + depth: 2, + masterFingerprint: 3974444335, + parentFingerprint: 2046425034, + index: 2147484149, + curve: 'ed25519', + privateKey: + '0x7acf6060833428c2196ce6e2c5ba5455394602814b9ec6b9bac453b357be7b24', + publicKey: + '0x00389ed03449fbc42a3ec134609b664a50e7a78bad800bad1629113590bfc9af9b', + chainCode: + '0x99d7cef35ae591a92eab31e0007f0199e3bea62d211a219526bf2ae06799886d', + }), + }, + writable: true, + }); + }); + + describe('when we have a valid path and we get a response from the Extension', () => { + it('returns the correct entropy', async () => { + const path = ['m', "44'", "501'"]; + const curve = 'ed25519'; + + await getBip32Entropy({ path, curve }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + }); + }); + + describe('when we have a valid entropy source and path and we get a response from the Extension', () => { + it('returns the correct entropy', async () => { + const entropySource = '01JR0PT6PNGBN7MRM3MPEVQPC0'; + const path = ['m', "44'", "501'"]; + const curve = 'ed25519'; + + await getBip32Entropy({ entropySource, path, curve }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { source: entropySource, path, curve }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts b/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts new file mode 100644 index 00000000..64dc0a1f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts @@ -0,0 +1,32 @@ +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import type { EntropySourceId } from '@metamask/keyring-api'; + +/** + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. + * + * @param params - The parameters for the Solana 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. + */ +export async function getBip32Entropy({ + entropySource, + path, + curve, +}: { + entropySource?: EntropySourceId | undefined; + path: string[]; + curve: 'secp256k1' | 'ed25519'; +}): Promise { + const node = await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + ...(entropySource ? { source: entropySource } : {}), + }, + }); + + return node; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.test.ts new file mode 100644 index 00000000..b866f40e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.test.ts @@ -0,0 +1,24 @@ +import { Network } from '../constants/solana'; +import { getClusterFromScope } from './getClusterFromScope'; + +describe('getClusterFromScope', () => { + it('returns "Mainnet" for mainnet network', () => { + const result = getClusterFromScope(Network.Mainnet); + expect(result).toBe('Mainnet'); + }); + + it('returns "Devnet" for devnet network', () => { + const result = getClusterFromScope(Network.Devnet); + expect(result).toBe('Devnet'); + }); + + it('returns "Testnet" for testnet network', () => { + const result = getClusterFromScope(Network.Testnet); + expect(result).toBe('Testnet'); + }); + + it('returns undefined for unknown network value', () => { + const result = getClusterFromScope('invalid:network' as unknown as Network); + expect(result).toBeUndefined(); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.ts b/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.ts new file mode 100644 index 00000000..79fb018c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getClusterFromScope.ts @@ -0,0 +1,17 @@ +import { Network } from '../constants/solana'; + +/** + * Returns the cluster name from the given CAIP-2 scope. + * + * @param lookedUpScope - The CAIP-2 scope to look up. + * @returns The cluster name or undefined if the scope is not found. + */ +export function getClusterFromScope(lookedUpScope: Network) { + for (const [scope, value] of Object.entries(Network)) { + if (value === lookedUpScope) { + return scope as keyof typeof Network; + } + } + + return undefined; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.test.ts new file mode 100644 index 00000000..2ceafcee --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.test.ts @@ -0,0 +1,60 @@ +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, +} from '../test/mocks/solana-keyring-accounts'; +import { getLowestUnusedIndex } from './getLowestUnusedIndex'; + +describe('getLowestUnusedIndex', () => { + it('returns 0 when no accounts exist', () => { + expect(getLowestUnusedIndex([])).toBe(0); + }); + + it('returns 1 when only index 0 is used', () => { + const accounts = [MOCK_SOLANA_KEYRING_ACCOUNT_0]; + expect(getLowestUnusedIndex(accounts)).toBe(1); + }); + + it('finds gap in sequential indices', () => { + // Gap at index 2 + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); + + it('handles unordered indices', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); + + it('returns next number after continuous sequence', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(3); + }); + + it('finds first gap with sparse indices', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.ts b/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.ts new file mode 100644 index 00000000..bcf5126e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getLowestUnusedIndex.ts @@ -0,0 +1,42 @@ +export type WithIndex = { + index: number; +}; + +/** + * Generating a new index for the KeyringAccount is not as straightforward as one might think. + * We cannot assume that this number will continuosly increase because one can delete an account with + * an index in the middle of the list. The right way to do it is to loop through the keyringAccounts + * and get the lowest index that is not yet used. + * + * This function does precisely that, in a generic way, as it can work with any array of items that + * have a field `index`. + * + * Eg: + * Used Indices: [] -> Lowest is 0. + * Used Indices: [0, 1, 2, 4] -> Lowest is 3. + * + * @param items - The items to check. + * @returns The lowest unused index. + */ +export function getLowestUnusedIndex(items: WithIndex[]): number { + if (items.length === 0) { + return 0; + } + + const usedIndices = items.map((item) => item.index).sort((a, b) => a - b); + + let lowestUnusedIndex = 0; + + for (const usedIndex of usedIndices) { + /** + * From lower to higher, the moment we find a gap, we can use it + */ + if (usedIndex !== lowestUnusedIndex) { + break; + } + + lowestUnusedIndex += 1; + } + + return lowestUnusedIndex; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.test.ts new file mode 100644 index 00000000..29507c24 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.test.ts @@ -0,0 +1,10 @@ +import { getNetworkFromToken } from './getNetworkFromToken'; + +describe('getNetworkFromToken', () => { + it('correctly extracts network from a valid CAIP-19 token string', () => { + const result = getNetworkFromToken( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/USDC', + ); + expect(result).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.ts b/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.ts new file mode 100644 index 00000000..0595eaf4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.ts @@ -0,0 +1,11 @@ +import type { Network } from '../constants/solana'; + +/** + * Extracts the network identifier from a CAIP-19 token string. + * + * @param caip19Token - The CAIP-19 token string, which is expected to be in the format 'network/token'. + * @returns The network identifier extracted from the CAIP-19 token string. + */ +export function getNetworkFromToken(caip19Token: string) { + return caip19Token.split('/')[0] as Network; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.test.ts new file mode 100644 index 00000000..d343f4d8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.test.ts @@ -0,0 +1,44 @@ +import { Network } from '../constants/solana'; +import { getSolanaExplorerUrl } from './getSolanaExplorerUrl'; + +describe('getSolanaExplorerUrl', () => { + const mockAddress = '5AcFMJZkXo14r3Hj99iYd1HScPiM4hAcLZf961Qs7y7'; + const mockTx = + '4RPWUVqAqW6jHbVuZH5qJuvoJM6EeX9m9Q6PC1RkcYBW3J4zY9LuZPZqNiYNXGm5qL6GJgCB7JqhXqV8vkKxnAHd'; + + it('should generate mainnet URL for address without cluster param', () => { + const url = getSolanaExplorerUrl(Network.Mainnet, 'address', mockAddress); + expect(url).toBe( + `https://solscan.io/address/${mockAddress}?cluster=mainnet`, + ); + }); + + it('should generate devnet URL for address with cluster param', () => { + const url = getSolanaExplorerUrl(Network.Devnet, 'address', mockAddress); + expect(url).toBe( + `https://solscan.io/address/${mockAddress}?cluster=devnet`, + ); + }); + + it('should generate testnet URL for address with cluster param', () => { + const url = getSolanaExplorerUrl(Network.Testnet, 'address', mockAddress); + expect(url).toBe( + `https://solscan.io/address/${mockAddress}?cluster=testnet`, + ); + }); + + it('should generate mainnet URL for transaction without cluster param', () => { + const url = getSolanaExplorerUrl(Network.Mainnet, 'tx', mockTx); + expect(url).toBe(`https://solscan.io/tx/${mockTx}?cluster=mainnet`); + }); + + it('should generate devnet URL for transaction with cluster param', () => { + const url = getSolanaExplorerUrl(Network.Devnet, 'tx', mockTx); + expect(url).toBe(`https://solscan.io/tx/${mockTx}?cluster=devnet`); + }); + + it('should generate testnet URL for transaction with cluster param', () => { + const url = getSolanaExplorerUrl(Network.Testnet, 'tx', mockTx); + expect(url).toBe(`https://solscan.io/tx/${mockTx}?cluster=testnet`); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.ts b/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.ts new file mode 100644 index 00000000..7792f68a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/getSolanaExplorerUrl.ts @@ -0,0 +1,32 @@ +import type { Network } from '../constants/solana'; +import { + DEFAULT_NETWORK_BLOCK_EXPLORER_URL, + Networks, +} from '../constants/solana'; +import { buildUrl } from './buildUrl'; + +/** + * Get the Solana Explorer URL for a given scope, type, and value. + * + * @param scope - The scope of the Solana network. + * @param type - The type of the value to explore. + * @param value - The value to explore. + * @returns The Solana Explorer URL. + */ +export function getSolanaExplorerUrl( + scope: Network, + type: 'address' | 'tx', + value: string, +) { + const { cluster } = Networks[scope]; + + const url = buildUrl({ + baseUrl: + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_BASE_URL ?? DEFAULT_NETWORK_BLOCK_EXPLORER_URL, + path: `/${type}/${value}`, + queryParams: cluster ? { cluster } : undefined, + }); + + return url; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/i18n.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/i18n.test.ts new file mode 100644 index 00000000..05f5c26f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/i18n.test.ts @@ -0,0 +1,30 @@ +import { i18n, locales } from './i18n'; + +describe('i18n', () => { + it('returns the correct translation for a given key', () => { + const translate = i18n('en'); + const message = translate('send.balance'); + expect(message).toBe(locales.en['send.balance'].message); + }); + + it('returns the correct translation for a given key and replaces', () => { + const translate = i18n('en'); + const message = translate('send.transaction-failure.subtitle', { + amount: '1.23', + tokenSymbol: 'SOL', + }); + expect(message).toBe('Unable to send 1.23 SOL'); + }); + + it('falls back to the default language if the preferred locale is not available', () => { + const translate = i18n('fr' as any); + const message = translate('send.balance'); + expect(message).toBe(locales.en['send.balance'].message); + }); + + it('returns the key for a non-existent key', () => { + const translate = i18n('en'); + const message = translate('nonExistentKey' as any); + expect(message).toBe('nonExistentKey'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/i18n.ts b/merged-packages/solana-wallet-snap/src/core/utils/i18n.ts new file mode 100644 index 00000000..2c251698 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/i18n.ts @@ -0,0 +1,40 @@ +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; +export type LocalizedMessage = keyof (typeof locales)[typeof FALLBACK_LANGUAGE]; + +/** + * 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: Locale) { + // Needs to be castes as EN is the main language and we can have the case where + // messages are not yed completed for the other languages + + const messages = (locales[locale] ?? + locales[FALLBACK_LANGUAGE]) as typeof en.messages; + + return (id: LocalizedMessage, replaces?: Record) => { + 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/solana-wallet-snap/src/core/utils/interface.ts b/merged-packages/solana-wallet-snap/src/core/utils/interface.ts new file mode 100644 index 00000000..0c493e56 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/interface.ts @@ -0,0 +1,261 @@ +import type { + ComponentOrElement, + DialogResult, + EntropySource, + GetClientStatusResult, + GetInterfaceStateResult, + Json, + ResolveInterfaceResult, + UpdateInterfaceResult, +} from '@metamask/snaps-sdk'; + +import type { ScheduleBackgroundEventMethod } from '../handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; +import { deserialize } from '../serialization/deserialize'; +import { serialize } from '../serialization/serialize'; +import type { Serializable } from '../serialization/types'; +import type { Preferences } from '../types/snap'; + +export const SEND_FORM_INTERFACE_NAME = 'send-form'; +export const CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME = + 'confirm-sign-and-send-transaction'; +export const CONFIRM_SIGN_MESSAGE_INTERFACE_NAME = 'confirm-sign-message'; +export const CONFIRM_SIGN_IN_INTERFACE_NAME = 'confirm-sign-in'; + +/** + * Creates an interface using the provided UI component and context. + * + * @param ui - The UI component or element. + * @param context - The context for the interface. + * @returns A promise that resolves to a string. + */ +export async function createInterface( + ui: ComponentOrElement, + context: TContext, +): Promise { + const serializedContext = serialize(context); + return snap.request({ + method: 'snap_createInterface', + params: { + ui, + context: serializedContext, + }, + }); +} + +/** + * Updates an interface using the provided UI component and context. + * + * @param id - The ID for the interface to update. + * @param ui - The UI component or element. + * @param context - The context for the interface. + * @returns A promise that resolves to a string. + */ +export async function updateInterface( + id: string, + ui: ComponentOrElement, + context: TContext, +): Promise { + const serializedContext = serialize(context); + return snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context: serializedContext, + }, + }); +} + +/** + * Gets the state of an interactive interface by its ID. + * + * @param id - The ID for the interface to update. + * @returns An object containing the state of the interface. + */ +export async function getInterfaceState( + id: string, +): Promise { + return snap.request({ + method: 'snap_getInterfaceState', + params: { + 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 snap.request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); +} + +/** + * Shows a dialog using the provided ID. + * + * @param id - The ID for the dialog. + * @returns A promise that resolves to a string. + */ +export async function showDialog(id: string): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + id, + }, + }); +} + +/** + * Get preferences from snap. + * + * @returns A promise that resolves to snap preferences. + */ +export async function getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }) as Promise; +} + +/** + * Retrieves the context of an interactive interface by its ID. + * + * @param interfaceId - The ID for the interface to retrieve the context. + * @returns An object containing the context of the interface. + */ +export async function getInterfaceContext( + interfaceId: string, +): Promise { + const rawContext = await snap.request({ + method: 'snap_getInterfaceContext', + params: { + id: interfaceId, + }, + }); + + if (!rawContext) { + return null; + } + + return deserialize(rawContext) as TContext; +} + +/** + * Retrieves the context of an interactive interface by its ID. + * + * @param interfaceId - The ID for the interface to retrieve the context. + * @returns An object containing the context of the interface. + * @throws If the interface context is not found. + */ +export async function getInterfaceContextOrThrow( + interfaceId: string, +): Promise { + const context = await getInterfaceContext(interfaceId); + if (!context) { + throw new Error('Interface context not found'); + } + return context; +} + +/** + * Retrieves the client status (locked/unlocked) in this case from MM. + * + * @returns An object containing the status. + */ +export async function getClientStatus(): Promise { + return await snap.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: ScheduleBackgroundEventMethod; + params?: Record; + duration: string; +}) { + return await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); +} + +/** + * List all entropy sources. + * + * @returns An array of entropy sources. + */ +export async function listEntropySources(): Promise { + return await snap.request({ + method: 'snap_listEntropySources', + }); +} + +/** + * Start a performance trace. + * + * @param name - The name of the trace. + * @returns A promise that resolves to a string. + */ +export async function startTrace(name: string) { + try { + const result = await snap.request({ + method: 'snap_startTrace', + params: { + name, + }, + }); + return result as number; + } catch (error) { + return null; + } +} + +/** + * End a performance trace. + * + * @param name - The name of the trace. + * @returns A promise that resolves to a string. + */ +export async function endTrace(name: string) { + try { + return await snap.request({ + method: 'snap_endTrace', + params: { + name, + }, + }); + } catch (error) { + return null; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/isFiat.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/isFiat.test.ts new file mode 100644 index 00000000..1e74f8e9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/isFiat.test.ts @@ -0,0 +1,25 @@ +import type { CaipAssetType } from '@metamask/keyring-api'; + +import { isFiat } from './isFiat'; + +describe('isFiat', () => { + it('should return true for valid fiat CAIP-19 asset IDs', () => { + expect(isFiat('swift:0/iso4217:USD')).toBe(true); + expect(isFiat('swift:0/iso4217:EUR')).toBe(true); + expect(isFiat('swift:0/iso4217:GBP')).toBe(true); + }); + + it('should return false for non-fiat CAIP-19 asset IDs', () => { + expect(isFiat('eip155:1/erc20:0x123')).toBe(false); + expect(isFiat('bip122:000000000019d6689c085ae165831e93/slip44:1')).toBe( + false, + ); + expect(isFiat('cosmos:cosmoshub-4/slip44:118')).toBe(false); + }); + + it('should return false for invalid or malformed asset IDs', () => { + expect(isFiat('' as CaipAssetType)).toBe(false); + expect(isFiat('swift:0' as CaipAssetType)).toBe(false); + expect(isFiat('iso4217:USD' as CaipAssetType)).toBe(false); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/isFiat.ts b/merged-packages/solana-wallet-snap/src/core/utils/isFiat.ts new file mode 100644 index 00000000..d245907e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/isFiat.ts @@ -0,0 +1,11 @@ +import type { CaipAssetType } from '@metamask/keyring-api'; + +/** + * Checks if a CAIP-19 asset ID represents a fiat currency. + * + * @param caipAssetId - The CAIP-19 asset ID to check. + * @returns `true` if the CAIP-19 asset ID represents a fiat currency, `false` otherwise. + */ +export function isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/logMaybeSolanaError.ts b/merged-packages/solana-wallet-snap/src/core/utils/logMaybeSolanaError.ts new file mode 100644 index 00000000..5d8a8064 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/logMaybeSolanaError.ts @@ -0,0 +1,39 @@ +import { getSystemErrorMessage, isSystemError } from '@solana-program/system'; +import type { Address } from '@solana/kit'; +import { isSolanaError } from '@solana/kit'; + +import logger from './logger'; + +type TransactionMessageIsh = { + instructions: Record< + number, + { + programAddress: Address; + } + >; +}; + +/** + * Generic logging function for Solana errors. + * + * @param error - The Solana error to log. + * @param transactionMessage - The transaction message to include in the error detail message. + */ +export const logMaybeSolanaError = ( + error: any, + transactionMessage: TransactionMessageIsh = { instructions: {} }, +) => { + if (isSolanaError(error)) { + const preflightErrorContext = error.context; + const preflightErrorMessage = error.message; + const errorDetailMessage = isSystemError(error.cause, transactionMessage) + ? getSystemErrorMessage(error.cause.context.code) + : error.cause; + logger.error( + preflightErrorContext, + '%s: %s', + preflightErrorMessage, + errorDetailMessage, + ); + } +}; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/logger.ts b/merged-packages/solana-wallet-snap/src/core/utils/logger.ts new file mode 100644 index 00000000..d3b253d0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/logger.ts @@ -0,0 +1,78 @@ +/** + * A simple logger utility that provides methods for logging messages at different levels. + * For now, it's just a wrapper around console. + * + * @namespace logger + */ + +import { logMaybeSolanaError } from './logMaybeSolanaError'; + +export type ILogger = { + log: (...args: any[]) => void; + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; + debug: (...args: any[]) => void; +}; + +const withSolanaErrorLogging = + (logFn: (...args: any[]) => void) => + (...args: any[]) => { + logMaybeSolanaError(args[0]); + logFn(...args); + }; + +/** + * A decorator function that noops if the environment is not local, + * and runs the decorated function otherwise. + * + * @param fn - The function to wrap. + * @returns The wrapped function. + */ +const withNoopInProduction = + (fn: (...args: any[]) => void) => + (...args: any[]) => { + // eslint-disable-next-line no-restricted-globals + if (process.env.ENVIRONMENT === 'production') { + return; + } + fn(...args); + }; + +/** + * A basic logger that wraps the console, extending its functionality to properly log Solana errors. + */ +const logger: ILogger = { + log: withNoopInProduction(console.log), + info: withNoopInProduction(console.info), + warn: withNoopInProduction(console.warn), + debug: withNoopInProduction(console.debug), + error: withNoopInProduction(withSolanaErrorLogging(console.error)), +}; + +export const noOpLogger: ILogger = { + log: () => {}, + info: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, +}; + +export const createPrefixedLogger = ( + _logger: ILogger, + prefix: string, +): ILogger => { + return new Proxy(_logger, { + get(target, prop: keyof ILogger) { + const method = target[prop]; + if (typeof method === 'function') { + return (message: string, ...args: any[]) => { + return method.call(target, prefix, message, ...args); + }; + } + return method; + }, + }); +}; + +export default logger; diff --git a/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.test.ts new file mode 100644 index 00000000..b209ed65 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.test.ts @@ -0,0 +1,106 @@ +import { METAMASK_ORIGIN } from '../constants/solana'; +import { parseOrigin } from './parseOrigin'; + +describe('parseOrigin', () => { + describe('when origin is MetaMask', () => { + it('returns "MetaMask" for metamask origin', () => { + expect(parseOrigin(METAMASK_ORIGIN)).toBe('MetaMask'); + }); + + it('returns "MetaMask" for exact string match', () => { + expect(parseOrigin('metamask')).toBe('MetaMask'); + }); + }); + + describe('when origin is a valid URL', () => { + it('returns hostname for HTTP URLs', () => { + expect(parseOrigin('http://example.com')).toBe('example.com'); + expect(parseOrigin('http://www.example.com')).toBe('www.example.com'); + expect(parseOrigin('http://sub.example.com')).toBe('sub.example.com'); + }); + + it('returns hostname for HTTPS URLs', () => { + expect(parseOrigin('https://example.com')).toBe('example.com'); + expect(parseOrigin('https://www.example.com')).toBe('www.example.com'); + expect(parseOrigin('https://sub.example.com')).toBe('sub.example.com'); + }); + + it('returns hostname for URLs with paths', () => { + expect(parseOrigin('https://example.com/path')).toBe('example.com'); + expect(parseOrigin('https://example.com/path/to/resource')).toBe( + 'example.com', + ); + expect(parseOrigin('https://example.com/path?query=value')).toBe( + 'example.com', + ); + }); + + it('returns hostname for URLs with query parameters', () => { + expect(parseOrigin('https://example.com?param=value')).toBe( + 'example.com', + ); + expect( + parseOrigin('https://example.com/path?param1=value1¶m2=value2'), + ).toBe('example.com'); + }); + + it('returns hostname for URLs with fragments', () => { + expect(parseOrigin('https://example.com#section')).toBe('example.com'); + expect(parseOrigin('https://example.com/path#section')).toBe( + 'example.com', + ); + }); + + it('returns hostname for URLs with ports', () => { + expect(parseOrigin('https://example.com:8080')).toBe('example.com'); + expect(parseOrigin('http://localhost:3000')).toBe('localhost'); + }); + + it('returns hostname for localhost URLs', () => { + expect(parseOrigin('http://localhost')).toBe('localhost'); + expect(parseOrigin('http://localhost:3000')).toBe('localhost'); + expect(parseOrigin('https://localhost')).toBe('localhost'); + }); + + it('returns hostname for IP addresses', () => { + expect(parseOrigin('http://192.168.1.1')).toBe('192.168.1.1'); + expect(parseOrigin('https://127.0.0.1')).toBe('127.0.0.1'); + expect(parseOrigin('http://192.168.1.1:8080')).toBe('192.168.1.1'); + }); + }); + + describe('edge cases', () => { + it('throws an error for URLs without protocol', () => { + expect(() => parseOrigin('//example.com')).toThrow('Invalid URL'); + expect(() => parseOrigin('//www.example.com')).toThrow('Invalid URL'); + }); + + it('handles URLs with custom protocols', () => { + expect(parseOrigin('ftp://example.com')).toBe('example.com'); + expect(parseOrigin('ws://example.com')).toBe('example.com'); + expect(parseOrigin('wss://example.com')).toBe('example.com'); + }); + + it('handles complex subdomains', () => { + expect(parseOrigin('https://api.v1.example.com')).toBe( + 'api.v1.example.com', + ); + expect(parseOrigin('https://dev.staging.example.com')).toBe( + 'dev.staging.example.com', + ); + }); + }); + + describe('error handling', () => { + it('throws error for invalid URLs', () => { + expect(() => parseOrigin('not-a-url')).toThrow('Invalid URL'); + expect(() => parseOrigin('http://')).toThrow('Invalid URL'); + expect(() => parseOrigin('https://')).toThrow('Invalid URL'); + expect(() => parseOrigin('')).toThrow('Invalid URL'); + }); + + it('throws error for malformed URLs', () => { + expect(() => parseOrigin('http://:8080')).toThrow('Invalid URL'); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.ts b/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.ts new file mode 100644 index 00000000..ad99e043 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/parseOrigin.ts @@ -0,0 +1,19 @@ +import { METAMASK_ORIGIN } from '../constants/solana'; + +/** + * Parses the origin from a string. + * + * @param origin - The origin to parse. + * @returns The parsed origin. + */ +export function parseOrigin(origin: string) { + if (origin === METAMASK_ORIGIN) { + return 'MetaMask'; + } + + try { + return new URL(origin).hostname; + } catch (error) { + throw new Error('Invalid URL'); + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/retry.ts b/merged-packages/solana-wallet-snap/src/core/utils/retry.ts new file mode 100644 index 00000000..940a0d3a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/retry.ts @@ -0,0 +1,34 @@ +/** + * Retry the passed promise until it resolves, retrying with a delay + * between attempts until the maximum number of attempts is reached. + * + * @param fn - The promise to retry. + * @param options - The options for the promise. + * @param options.maxAttempts - The maximum number of attempts. + * @param options.delayMs - The delay between attempts in milliseconds. + * @returns The result of the promise. + */ +export async function retry( + fn: () => Promise | TResult, + options?: { + maxAttempts?: number; + delayMs?: number; + }, +): Promise { + const maxAttempts = options?.maxAttempts ?? 10; + const delayMs = options?.delayMs ?? 1000; + + let attempts = 0; + while (attempts < maxAttempts) { + try { + return await fn(); + } catch (error) { + attempts += 1; + if (attempts === maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw new Error('Unreachable'); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/safeMerge.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/safeMerge.test.ts new file mode 100644 index 00000000..93666ab1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/safeMerge.test.ts @@ -0,0 +1,119 @@ +import { safeMerge } from './safeMerge'; + +describe('safeMerge', () => { + it('merges two objects and keeps existing values when 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 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('handles nested objects', () => { + 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 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 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/solana-wallet-snap/src/core/utils/safeMerge.ts b/merged-packages/solana-wallet-snap/src/core/utils/safeMerge.ts new file mode 100644 index 00000000..69a12472 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/safeMerge.ts @@ -0,0 +1,28 @@ +/** + * Merges two objects, keeping values from the overridee object when the overrider's corresponding 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. + * + * @param overridee - The object to override. + * @param overrider - The object to override with. + * @returns The merged object. + * @example + * const overridee = { name: 'John' }; + * const overrider = { name: undefined, age: 30 }; + * const merged = safeMerge(overridee, overrider); + * // merged is { name: 'John' } + */ +export const safeMerge = ( + overridee: TOverridee, + overrider: TOverrider, +): TOverridee & TOverrider => ({ + ...overridee, + ...(Object.fromEntries( + Object.entries(overrider).filter( + ([_, value]) => + value !== undefined && + value !== null && + (!value || typeof value !== 'object' || Object.keys(value).length > 0), + ), + ) as TOverrider), +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/sanitize.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/sanitize.test.ts new file mode 100644 index 00000000..249167e1 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/sanitize.test.ts @@ -0,0 +1,211 @@ +import { + sanitizeControlCharacters, + sanitizeForSignInMessage, + sanitizeDomain, + sanitizeSolanaAddress, + sanitizeUri, + sanitizeTimestamp, + sanitizeResources, +} from './sanitize'; + +describe('sanitize', () => { + describe('sanitizeControlCharacters', () => { + it('removes control characters from strings', () => { + expect(sanitizeControlCharacters('hello\nworld')).toBe('helloworld'); + expect(sanitizeControlCharacters('hello\r\nworld')).toBe('helloworld'); + // The tab character is preserved + expect(sanitizeControlCharacters('hello\tworld')).toBe('hello\tworld'); + expect(sanitizeControlCharacters('hello\x00world')).toBe('helloworld'); + expect(sanitizeControlCharacters('hello\x1Fworld')).toBe('helloworld'); + }); + + it('handles edge cases', () => { + expect(sanitizeControlCharacters('')).toBe(''); + expect(sanitizeControlCharacters(null as any)).toBe(''); + expect(sanitizeControlCharacters(undefined as any)).toBe(''); + expect(sanitizeControlCharacters('normal text')).toBe('normal text'); + }); + }); + + describe('sanitizeForSignInMessage', () => { + it('sanitizes strings for sign-in messages', () => { + expect(sanitizeForSignInMessage('hello\nworld')).toBe('helloworld'); + expect(sanitizeForSignInMessage('normal text')).toBe('normal text'); + }); + + it('limits length', () => { + const longString = 'a'.repeat(2000); + const result = sanitizeForSignInMessage(longString, 100); + expect(result).toHaveLength(100); + }); + + it('handles edge cases', () => { + expect(sanitizeForSignInMessage('')).toBe(''); + expect(sanitizeForSignInMessage(null as any)).toBe(''); + expect(sanitizeForSignInMessage(undefined as any)).toBe(''); + }); + }); + + describe('sanitizeDomain', () => { + it('validates and sanitizes valid domains', () => { + expect(sanitizeDomain('example.com')).toBe('example.com'); + expect(sanitizeDomain('sub.example.com')).toBe('sub.example.com'); + expect(sanitizeDomain('EXAMPLE.COM')).toBe('example.com'); + expect(sanitizeDomain('test-domain.com')).toBe('test-domain.com'); + }); + + it('rejects invalid domains', () => { + expect(sanitizeDomain('')).toBe(''); + expect(sanitizeDomain('not-a-domain')).toBe(''); + expect(sanitizeDomain('-invalid.com')).toBe(''); + expect(sanitizeDomain('invalid-.com')).toBe(''); + }); + + it('sanitizes domains with control characters', () => { + expect(sanitizeDomain('example.com\n')).toBe('example.com'); + expect(sanitizeDomain('sub.example.com\r')).toBe('sub.example.com'); + }); + + it('handles edge cases', () => { + expect(sanitizeDomain(null as any)).toBe(''); + expect(sanitizeDomain(undefined as any)).toBe(''); + }); + }); + + describe('sanitizeSolanaAddress', () => { + it('validates and sanitizes valid Solana addresses', () => { + expect( + sanitizeSolanaAddress('5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy'), + ).toBe('5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy'); + expect(sanitizeSolanaAddress('11111111111111111111111111111112')).toBe( + '11111111111111111111111111111112', + ); + }); + + it('rejects invalid addresses', () => { + expect(sanitizeSolanaAddress('')).toBe(''); + expect(sanitizeSolanaAddress('invalid-address')).toBe(''); + // Short address + expect(sanitizeSolanaAddress('123')).toBe(''); + // Long address + expect(sanitizeSolanaAddress('a'.repeat(50))).toBe(''); + }); + + it('sanitizes addresses with control characters', () => { + expect( + sanitizeSolanaAddress('5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy\n'), + ).toBe('5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy'); + expect(sanitizeSolanaAddress('11111111111111111111111111111112\r')).toBe( + '11111111111111111111111111111112', + ); + }); + + it('handles edge cases', () => { + expect(sanitizeSolanaAddress(null as any)).toBe(''); + expect(sanitizeSolanaAddress(undefined as any)).toBe(''); + }); + }); + + describe('sanitizeUri', () => { + it('validates and sanitizes valid URIs', () => { + expect(sanitizeUri('https://example.com')).toBe('https://example.com'); + expect(sanitizeUri('http://example.com/path')).toBe( + 'http://example.com/path', + ); + expect(sanitizeUri('wss://example.com')).toBe('wss://example.com'); + }); + + it('rejects invalid URIs', () => { + expect(sanitizeUri('')).toBe(''); + expect(sanitizeUri('not-a-url')).toBe(''); + expect(sanitizeUri('ftp://example.com')).toBe(''); + // eslint-disable-next-line no-script-url + expect(sanitizeUri('javascript:alert(1)')).toBe(''); + }); + + it('sanitizes URIs with control characters', () => { + expect(sanitizeUri('https://example.com\n')).toBe('https://example.com'); + expect(sanitizeUri('http://example.com/path\r')).toBe( + 'http://example.com/path', + ); + }); + + it('handles edge cases', () => { + expect(sanitizeUri(null as any)).toBe(''); + expect(sanitizeUri(undefined as any)).toBe(''); + }); + }); + + describe('sanitizeTimestamp', () => { + it('validates and sanitizes valid timestamps', () => { + expect(sanitizeTimestamp('2024-01-01T00:00:00.000Z')).toBe( + '2024-01-01T00:00:00.000Z', + ); + expect(sanitizeTimestamp('2024-01-01T00:00:00Z')).toBe( + '2024-01-01T00:00:00Z', + ); + }); + + it('rejects invalid timestamps', () => { + expect(sanitizeTimestamp('')).toBe(''); + expect(sanitizeTimestamp('not-a-timestamp')).toBe(''); + }); + + it('sanitizes timestamps with control characters', () => { + expect(sanitizeTimestamp('2024-01-01T00:00:00.000Z\n')).toBe( + '2024-01-01T00:00:00.000Z', + ); + expect(sanitizeTimestamp('2024-01-01T00:00:00Z\r')).toBe( + '2024-01-01T00:00:00Z', + ); + }); + + it('handles edge cases', () => { + expect(sanitizeTimestamp(null as any)).toBe(''); + expect(sanitizeTimestamp(undefined as any)).toBe(''); + }); + }); + + describe('sanitizeResources', () => { + it('validates and sanitizes valid resources', () => { + const resources = [ + 'https://example.com/resource1', + 'https://example.com/resource2', + 'wss://example.com/ws', + ]; + expect(sanitizeResources(resources)).toStrictEqual(resources); + }); + + it('filters out invalid resources', () => { + const resources = [ + 'https://example.com/valid', + 'invalid-url', + 'https://example.com/valid2', + 'ftp://example.com/invalid', + ]; + expect(sanitizeResources(resources)).toStrictEqual([ + 'https://example.com/valid', + 'https://example.com/valid2', + ]); + }); + + it('sanitizes resources with control characters', () => { + const resources = [ + 'https://example.com/resource1\n', + 'https://example.com/resource2\r', + 'wss://example.com/ws', + ]; + expect(sanitizeResources(resources)).toStrictEqual([ + 'https://example.com/resource1', + 'https://example.com/resource2', + 'wss://example.com/ws', + ]); + }); + + it('handles edge cases', () => { + expect(sanitizeResources([])).toStrictEqual([]); + expect(sanitizeResources(null as any)).toStrictEqual([]); + expect(sanitizeResources(undefined as any)).toStrictEqual([]); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/sanitize.ts b/merged-packages/solana-wallet-snap/src/core/utils/sanitize.ts new file mode 100644 index 00000000..b787a873 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/sanitize.ts @@ -0,0 +1,190 @@ +/** + * Removes control characters from a string. + * + * @param input - The string to sanitize. + * @returns The sanitized string. + */ +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, ''); +} + +/** + * Sanitizes a string for use in sign-in messages. + * + * @param input - The string to sanitize. + * @param maxLength - Maximum allowed length. + * @returns The sanitized string. + */ +export function sanitizeForSignInMessage( + input: string, + maxLength = 1000, +): string { + if (!input || typeof input !== 'string') { + return ''; + } + + // Removes control characters + let sanitized = sanitizeControlCharacters(input); + + // Limit length for all inputs + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength); + } + + // Trim whitespace + sanitized = sanitized.trim(); + + // If sanitization didn't change anything, return the original + if (sanitized === input) { + return sanitized; + } + + return sanitized; +} + +/** + * Validates and sanitizes a domain name. + * + * @param domain - The domain to validate and sanitize. + * @returns The sanitized domain or empty string if invalid. + */ +export function sanitizeDomain(domain: string): string { + if (!domain || typeof domain !== 'string') { + return ''; + } + + let sanitized = sanitizeControlCharacters(domain); + + // Extract valid domain part from potentially malicious input + const domainMatch = sanitized.match( + /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+)/u, + ); + if (domainMatch?.[1]) { + sanitized = domainMatch[1]; + } + + // Basic domain validation - should contain at least one dot and valid characters + const domainRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/u; + + if (!domainRegex.test(sanitized)) { + return ''; + } + + // RFC 1035 domain name length limit + if (sanitized.length > 253) { + return ''; + } + + return sanitized.toLowerCase(); +} + +/** + * Validates and sanitizes a Solana address. + * + * @param address - The address to validate and sanitize. + * @returns The sanitized address or empty string if invalid. + */ +export function sanitizeSolanaAddress(address: string): string { + if (!address || typeof address !== 'string') { + return ''; + } + + const sanitized = sanitizeControlCharacters(address); + + // Basic Solana address validation (Base58 format) + const base58Regex = /^[1-9A-HJ-NP-Za-km-z]+$/u; + + if (!base58Regex.test(sanitized)) { + return ''; + } + + // Solana addresses are 32 or 44 characters long + if (sanitized.length < 32 || sanitized.length > 44) { + return ''; + } + + return sanitized; +} + +/** + * 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:', 'ipfs:']; + if (!allowedProtocols.includes(url.protocol)) { + return ''; + } + if (sanitized.length > 2048) { + return ''; + } + return sanitized; + } catch { + return ''; + } +} + +/** + * Validates and sanitizes a timestamp string. + * + * @param timestamp - The timestamp to validate and sanitize. + * @returns The sanitized timestamp or empty string if invalid. + */ +export function sanitizeTimestamp(timestamp: string): string { + if (!timestamp || typeof timestamp !== 'string') { + return ''; + } + + const sanitized = sanitizeControlCharacters(timestamp); + + // Basic ISO 8601 timestamp validation + const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/u; + + if (!isoRegex.test(sanitized)) { + return ''; + } + + // Validate that it's a valid date + const date = new Date(sanitized); + if (isNaN(date.getTime())) { + return ''; + } + + return sanitized; +} + +/** + * Validates and sanitizes an array of resource strings. + * + * @param resources - The resources array to validate and sanitize. + * @returns The sanitized resources array. + */ +export function sanitizeResources(resources: string[]): string[] { + if (!Array.isArray(resources)) { + return []; + } + + const sanitized = resources + .filter((resource) => typeof resource === 'string') + .map((resource) => sanitizeUri(resource)) + .filter((resource) => resource !== ''); + + return sanitized; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/sleep.ts b/merged-packages/solana-wallet-snap/src/core/utils/sleep.ts new file mode 100644 index 00000000..b6e6da27 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/sleep.ts @@ -0,0 +1,9 @@ +/** + * Sleep function to wait for a given number of milliseconds. + * + * @param ms - The number of milliseconds to sleep. + * @returns A promise that resolves when the sleep is complete. + */ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/stack.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/stack.test.ts new file mode 100644 index 00000000..c6d1a9a0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/stack.test.ts @@ -0,0 +1,72 @@ +import { Stack } from './stack'; + +describe('Stack', () => { + let stack: Stack; + + beforeEach(() => { + stack = new Stack(); + }); + + describe('push', () => { + it('adds items to the stack', () => { + stack.push(1); + expect(stack.size()).toBe(1); + }); + + it('throws error when exceeding capacity', () => { + const limitedStack = new Stack(1); + limitedStack.push(1); + expect(() => limitedStack.push(2)).toThrow( + 'Stack has reached max capacity', + ); + }); + }); + + describe('pop', () => { + it('returns undefined for empty stack', () => { + expect(stack.pop()).toBeUndefined(); + }); + + it('removes and returns destackable items', () => { + stack.push(1); + stack.push(2); + + expect(stack.pop()).toBe(2); + expect(stack.size()).toBe(1); + expect(stack.pop()).toBe(1); + expect(stack.size()).toBe(0); + }); + + it('returns but does not remove non-destackable items', () => { + stack.push(1); + stack.push(2, false); // non-destackable item + + expect(stack.pop()).toBe(2); + expect(stack.size()).toBe(2); // size remains the same + expect(stack.pop()).toBe(2); // same item returned again + expect(stack.size()).toBe(2); // size still remains the same + }); + }); + + describe('size', () => { + it('returns correct stack size', () => { + expect(stack.size()).toBe(0); + + stack.push(1); + expect(stack.size()).toBe(1); + + stack.push(2); + expect(stack.size()).toBe(2); + + stack.pop(); + expect(stack.size()).toBe(1); + }); + }); + + describe('peek', () => { + it('returns the last item without removing it', () => { + stack.push(1); + expect(stack.peek()).toBe(1); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/stack.ts b/merged-packages/solana-wallet-snap/src/core/utils/stack.ts new file mode 100644 index 00000000..c39f5685 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/stack.ts @@ -0,0 +1,54 @@ +export type IStack = { + push: (item: TItem) => void; + pop: () => TItem | undefined; + size: () => number; +}; + +export type StackItem = { + value: TItem; + isDestackable: boolean; +}; + +/** + * A stack implementation that allows for non-destackable items to be returned + * without removing them from the stack. + */ +export class Stack implements IStack { + readonly #capacity: number; + + readonly #storage: StackItem[] = []; + + constructor(capacity = Infinity) { + this.#capacity = capacity; + } + + push(item: TItem, isDestackable = true): void { + if (this.size() === this.#capacity) { + throw Error('Stack has reached max capacity, you cannot add more items'); + } + this.#storage.push({ value: item, isDestackable }); + } + + pop(): TItem | undefined { + const lastItem = this.#storage[this.#storage.length - 1]; + + if (!lastItem) { + return undefined; + } + + if (lastItem.isDestackable) { + // Remove the item from the stack and return it + return this.#storage.pop()?.value; + } + // Return the item without removing it from the stack + return lastItem.value; + } + + size(): number { + return this.#storage.length; + } + + peek(): TItem | undefined { + return this.#storage[this.#storage.length - 1]?.value; + } +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.test.ts new file mode 100644 index 00000000..7c860ca3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.test.ts @@ -0,0 +1,49 @@ +import BigNumber from 'bignumber.js'; + +import { toTokenUnits } from './toTokenUnit'; + +describe('toTokenUnits', () => { + it('converts string amounts correctly', () => { + expect(toTokenUnits('1', 6)).toBe(1000000n); + expect(toTokenUnits('0.1', 6)).toBe(100000n); + expect(toTokenUnits('0.000001', 6)).toBe(1n); + }); + + it('converts number amounts correctly', () => { + expect(toTokenUnits(1, 6)).toBe(1000000n); + expect(toTokenUnits(0.1, 6)).toBe(100000n); + }); + + it('converts BigNumber amounts correctly', () => { + expect(toTokenUnits(new BigNumber('1'), 6)).toBe(1000000n); + expect(toTokenUnits(new BigNumber('0.1'), 6)).toBe(100000n); + }); + + it('converts bigint amounts correctly', () => { + expect(toTokenUnits(1n, 6)).toBe(1000000n); + }); + + it('handles zero correctly', () => { + expect(toTokenUnits('0', 6)).toBe(0n); + expect(toTokenUnits(0, 6)).toBe(0n); + expect(toTokenUnits(new BigNumber(0), 6)).toBe(0n); + expect(toTokenUnits(0n, 6)).toBe(0n); + }); + + it('throws error for negative amounts', () => { + expect(() => toTokenUnits('-1', 6)).toThrow( + 'Token amount cannot be negative', + ); + expect(() => toTokenUnits(-1, 6)).toThrow( + 'Token amount cannot be negative', + ); + expect(() => toTokenUnits(new BigNumber(-1), 6)).toThrow( + 'Token amount cannot be negative', + ); + }); + + it('returns zero for too many decimal places', () => { + expect(toTokenUnits('0.0000001', 6)).toBe(0n); + expect(toTokenUnits(0.0000001, 6)).toBe(0n); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.ts b/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.ts new file mode 100644 index 00000000..0684248c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/toTokenUnit.ts @@ -0,0 +1,24 @@ +import BigNumber from 'bignumber.js'; + +/** + * Converts a human-readable token amount to raw token units. + * + * @param amount - The amount in token (e.g., "0.1"). + * @param decimals - The number of decimals the token has (e.g., 6 for USDC). + * @returns The amount in raw units. + * @throws If the amount is negative or would result in an underflow. + */ +export function toTokenUnits( + amount: string | number | bigint | BigNumber, + decimals: number, +): bigint { + const bn = new BigNumber(amount.toString()); + + if (bn.isNegative()) { + throw new Error('Token amount cannot be negative'); + } + + const result = bn.times(10 ** decimals).integerValue(BigNumber.ROUND_DOWN); + + return BigInt(result.toString()); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/tokenAddressToCaip19.ts b/merged-packages/solana-wallet-snap/src/core/utils/tokenAddressToCaip19.ts new file mode 100644 index 00000000..62cf7470 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/tokenAddressToCaip19.ts @@ -0,0 +1,15 @@ +import type { Network, TokenCaipAssetType } from '../constants/solana'; + +/** + * Converts a token address to a CAIP-19 identifier. + * + * @param scope - The network scope. + * @param address - The token address. + * @returns The CAIP-19 identifier. + */ +export function tokenAddressToCaip19( + scope: Network, + address: string, +): TokenCaipAssetType { + return `${scope}/token:${address}`; +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.test.ts new file mode 100644 index 00000000..0daa3c03 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.test.ts @@ -0,0 +1,38 @@ +import { tokenToFiat } from './tokenToFiat'; + +describe('tokenToFiat', () => { + it('converts token amount to fiat correctly', () => { + const tokenAmount = '100'; + const exchangeRate = 2; + const fiatAmount = tokenToFiat(tokenAmount, exchangeRate); + expect(fiatAmount).toBe('200'); + }); + + it('handles zero token amount', () => { + const tokenAmount = '0'; + const exchangeRate = 2; + const fiatAmount = tokenToFiat(tokenAmount, exchangeRate); + expect(fiatAmount).toBe('0'); + }); + + it('handles zero exchange rate', () => { + const tokenAmount = '100'; + const exchangeRate = 0; + const fiatAmount = tokenToFiat(tokenAmount, exchangeRate); + expect(fiatAmount).toBe('0'); + }); + + it('handles negative token amount', () => { + const tokenAmount = '-100'; + const exchangeRate = 2; + const fiatAmount = tokenToFiat(tokenAmount, exchangeRate); + expect(fiatAmount).toBe('-200'); + }); + + it('handles negative exchange rate', () => { + const tokenAmount = '100'; + const exchangeRate = -2; + const fiatAmount = tokenToFiat(tokenAmount, exchangeRate); + expect(fiatAmount).toBe('-200'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.ts b/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.ts new file mode 100644 index 00000000..de138218 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/tokenToFiat.ts @@ -0,0 +1,13 @@ +import BigNumber from 'bignumber.js'; + +/** + * 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: number) { + const bigAmount = new BigNumber(tokenAmount); + return bigAmount.multipliedBy(rateConversion).toString(); +} diff --git a/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.test.ts b/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.test.ts new file mode 100644 index 00000000..a4b70abb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.test.ts @@ -0,0 +1,27 @@ +import { truncateAddress } from './truncateAddress'; + +describe('truncateAddress', () => { + it('truncates a long address correctly', () => { + const address = '1234567890abcdef'; + const truncated = truncateAddress(address); + expect(truncated).toBe('123456...cdef'); + }); + + it('handles empty addresses gracefully', () => { + const address = ''; + const truncated = truncateAddress(address); + expect(truncated).toBe(''); + }); + + it('handles short addresses', () => { + const address = '123456'; + const truncated = truncateAddress(address); + expect(truncated).toBe(''); + }); + + it('handles addresses with more than 10 characters', () => { + const address = '12345678900'; + const truncated = truncateAddress(address); + expect(truncated).toBe('123456...8900'); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.ts b/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.ts new file mode 100644 index 00000000..088058da --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/utils/truncateAddress.ts @@ -0,0 +1,17 @@ +/** + * Truncates an address to show the first 6 and last 4 characters, separated by ellipses. + * + * @param address - The address to truncate. + * @returns The truncated address. + */ +export function truncateAddress(address: string): string { + if (!address) { + return ''; + } + + if (address.length <= 10) { + return ''; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} diff --git a/merged-packages/solana-wallet-snap/src/core/validation/structs.test.ts b/merged-packages/solana-wallet-snap/src/core/validation/structs.test.ts new file mode 100644 index 00000000..ad1eed6d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/validation/structs.test.ts @@ -0,0 +1,243 @@ +/* eslint-disable jest/require-to-throw-message */ +import { assert, is } from '@metamask/superstruct'; + +import { Base58Struct, Base64Struct, UrlStruct, UuidStruct } from './structs'; + +describe('structs', () => { + describe('Uuid', () => { + it('validates valid UUIDs', () => { + const validUuids = [ + 'c747acb9-1b2b-4352-b9da-3d658fcc3cc7', + '2507a426-ac26-43c4-a82a-250f5d999398', + '52d181f4-d050-4971-b448-17c15107fa3b', + ]; + + validUuids.forEach((uuid) => { + expect(() => assert(uuid, UuidStruct)).not.toThrow(); + expect(is(uuid, UuidStruct)).toBe(true); + }); + }); + }); + + describe('UrlStruct', () => { + it('validates valid URLs', () => { + const validUrls = [ + 'http://example.com', + 'https://example.com', + 'https://www.example.com', + 'https://sub.example.com', + 'https://example.com/path', + 'https://example.com/path?query=123', + 'https://example.com/path?query=123&other=456', + 'https://example.com:8080', + 'https://example.com/path-with-hyphens', + 'https://example.com/path_with_underscore', + 'http://localhost:8899', + 'wss://example.com', + ]; + + validUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).not.toThrow(); + expect(is(url, UrlStruct)).toBe(true); + }); + }); + + it('rejects invalid URLs', () => { + const invalidUrls = [ + '', + 'not-a-url', + 'ftp://example.com', + 'example.com', + 'http:/example.com', + 'http://example', + 'http:///example.com', + 'http:// example.com', + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', + ]; + + invalidUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + + it('rejects malicious URLs', () => { + const maliciousUrls = [ + // XSS Attacks + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + + // Additional XSS Variants + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + + // SQL Injection Attempts + 'https://example.com?id=1%27%20OR%20%271%27=%271', + 'https://example.com?id=1%20UNION%20SELECT%20*%20FROM%20users', + + // Directory Traversal + 'https://example.com/../../../etc/passwd', + 'https://example.com/..%2f..%2f..%2fetc%2fpasswd', + + // Command Injection + 'https://example.com?cmd=|ls', + 'https://example.com?cmd=;cat%20/etc/passwd', + + // Protocol Pollution + 'https://example.com\\@evil.com', + 'https://example.com%2f@evil.com', + 'https://example.com?url=javascript:alert(1)', + + // Unicode/UTF-8 Attacks + 'https://example.com/%2e%2e/%2e%2e/%2e%2e/etc/passwd', + 'https://example.com/⒕⒖⒗', + + // CRLF Injection + 'https://example.com?%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK', + + // Open Redirect + 'https://example.com?redirect=//evil.com', + 'https://example.com?url=https://evil.com', + + // HTML Injection without script tags + 'https://example.com?param=test', + 'https://example.com?param=', + + // Data URI schemes + 'data:text/html,', + 'data:application/x-javascript,alert(1)', + + // Null Byte Attacks + 'https://example.com/file.jpg%00.php', + + // Template Injection + // eslint-disable-next-line no-template-curly-in-string + 'https://example.com?${7*7}', + 'https://example.com?#{7*7}', + ]; + maliciousUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + }); + + describe('Base58Struct', () => { + it('validates valid Base58 strings', () => { + const validBase58Strings = [ + '72k1xXWG59wUsYv7h2', // Decoded: "Hello, world!" + '3yZe7d', // Decoded: "Test" + 'JxF12TrwUP45BMd', // Decoded: "Base58 Example" + '5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy', // Example Solana address + 'Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD', // Example IPFS hash + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', // All valid Base58 chars + ]; + + validBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).not.toThrow(); + expect(is(base58String, Base58Struct)).toBe(true); + }); + }); + + it('rejects invalid Base58 strings', () => { + const invalidBase58Strings = [ + 'invalid-base58-string', + '', // empty string + '0', // 0 is not used in Base58 + 'O', // uppercase O is not used in Base58 + 'l', // lowercase L is not used in Base58 + 'I', // uppercase i is not used in Base58 + 'hello world', // spaces not allowed + 'base+58', // special characters not allowed + '12345!', // exclamation mark not allowed + 'ABC 123', // spaces not allowed + 'TEST@123', // @ symbol not allowed + '你好', // non-ASCII characters + 'BASE_58', // underscore not allowed + 'test\n123', // newlines not allowed + 'test\t123', // tabs not allowed + ]; + + invalidBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).toThrow(); + expect(is(base58String, Base58Struct)).toBe(false); + }); + }); + }); + + describe('Base64Struct', () => { + it('validates valid Base64 strings', () => { + const validBase64Strings = [ + 'SGVsbG8sIFdvcmxkIQ==', // "Hello, World!" + 'dGVzdA==', // "test" + 'YWJzb2x1dGU=', // "aBc" + 'aGVsbG8=', // "hello" + 'aGVsbG8sIHdvcmxkIQ==', // "hello, world!" + ]; + + validBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).not.toThrow(); + expect(is(base64String, Base64Struct)).toBe(true); + }); + }); + + it('rejects invalid Base64 strings', () => { + const invalidBase64Strings = [ + // Invalid characters + 'ABC!DEF', // Contains '!' which is not in Base64 alphabet + 'ABC DEF', // Contains space which is not in Base64 alphabet + 'ABC_DEF', // Contains '_' which is not in standard Base64 (but is in URL-safe variant) + 'ABC-DEF', // Contains '-' which is not in standard Base64 (but is in URL-safe variant) + + // Invalid padding + 'A=', // Single character with padding (should be 'A===') + 'AB=', // Two characters with single padding (should be 'AB==') + 'ABC=A', // Padding in the middle + 'ABCD=', // Padding when not needed (length is multiple of 4) + 'A===', // Too much padding for a single character + 'AB===', // Too much padding for two characters + 'ABC==', // Too much padding for three characters + + // Invalid length + 'A', // Single character without proper padding + 'AB', // Two characters without proper padding + 'ABC', // Three characters without proper padding + + // Padding in wrong position + '=ABC', // Padding at the beginning + 'A=BC', // Padding in the middle + 'AB=C', // Padding in the middle + + // Mixed issues + 'A=B=C=', // Multiple padding characters in wrong positions + 'AB!C=', // Invalid character and padding + 'ABCDE=', // Length not a multiple of 4 with incorrect padding + + // Empty string + '', // Empty string is not valid Base64 + + // URL-safe Base64 characters in standard Base64 + 'ABC-DEF+GHI', // Mixed URL-safe and standard Base64 + 'ABC_DEF/GHI', // Mixed URL-safe and standard Base64 + ]; + + invalidBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).toThrow(base64String); + expect(is(base64String, Base64Struct)).toBe(false); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/core/validation/structs.ts b/merged-packages/solana-wallet-snap/src/core/validation/structs.ts new file mode 100644 index 00000000..55df01d8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/validation/structs.ts @@ -0,0 +1,330 @@ +import { CaipAssetTypeStruct, SolMethod } from '@metamask/keyring-api'; +import { ExportAccountOptionsStruct } from '@metamask/keyring-api/v2'; +import type { Struct } from '@metamask/superstruct'; +import { + array, + define, + enums, + integer, + nullable, + object, + optional, + pattern, + record, + refine, + string, +} from '@metamask/superstruct'; +import { address } from '@solana/kit'; + +import { Network } from '../constants/solana'; + +// create a uuid validation +export const UuidStruct = pattern( + string(), + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, +); + +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * Validates that a string is a valid and safe URL. + * + * It rejects: + * - Non-HTTP/HTTPS/WSS protocols + * - Malformed URL format or incorrect protocol format + * - Invalid hostname format (must follow proper domain naming conventions) + * - Protocol pollution attempts (backslashes, @ symbol, %2f@, %5c@) + * - Invalid hostname characters (backslashes, @ symbol, forward slashes, encoded forward slashes) + * - Directory traversal attempts (../, ..%2f, ..%2F) + * + * Dangerous patterns including: + * - HTML tags. + * - JavaScript protocol. + * - Data URI scheme. + * - Template injection (${...}, #{...}). + * - Command injection (|, ;). + * - CRLF injection. + * - URL credential injection. + * - SQL injection attempts. + * - Open redirect parameters. + * - Non-printable characters. + */ +export const UrlStruct = refine(string(), 'safe-url', (value) => { + try { + // Basic URL validation + const url = new URL(value); + + // Protocol check + const supportedProtocols = ['http:', 'https:', 'wss:']; + if (!supportedProtocols.includes(url.protocol)) { + return `URL must use one of the following protocols: ${supportedProtocols}`; + } + + // Validate URL format + if (!value.match(/^(https?|wss):\/\/[^/]+\/?/u)) { + return 'Malformed URL - incorrect protocol format'; + } + + // Validate hostname format. Accepts localhost and ports (needed for tests) + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== 'localhost' && + (!hostname.includes('.') || + !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/u.test( + hostname, + )) + ) { + return 'Invalid hostname format'; + } + + // Check for protocol pollution in the entire URL + const decodedValue = decodeURIComponent(value.toLowerCase()); + if ( + value.includes('\\') || + value.includes('@') || + decodedValue.includes('\\') || + decodedValue.includes('@') || + value.toLowerCase().includes('%2f@') || + value.toLowerCase().includes('%5c@') + ) { + return 'URL contains protocol pollution attempts'; + } + + // Additional hostname safety check for protocol pollution + const decodedHostname = decodeURIComponent(hostname); + if ( + hostname.includes('\\') || + hostname.includes('@') || + decodedHostname.includes('/') || + hostname.toLowerCase().includes('%2f') + ) { + return 'Invalid hostname characters detected'; + } + + // Check for directory traversal + if ( + value.includes('../') || + value.includes('..%2f') || + value.includes('..%2F') + ) { + return 'Directory traversal attempts are not allowed'; + } + + // Check for dangerous patterns + const dangerousPatterns = [ + /<[^>]*>/u, // HTML tags + /javascript:/u, // JavaScript protocol + /data:/u, // Data URI scheme + /\\[@\\]/u, // Enhanced protocol pollution check + /%2f@/u, // Protocol pollution + /[^\x20-\x7E]/u, // Non-printable characters + /\$\{.*?\}/u, // Template injection + /#\{.*?\}/u, // Template injection + /[|;]/u, // Command injection + /%0[acd]|%0[acd]/u, // CRLF injection + /\/\/\w+@/u, // URL credential injection + // Enhanced SQL injection patterns + /(?:[^a-z]|^)(?:union\s+(?:all\s+)?select|select\s+(?:.*\s+)?from|insert\s+into|update\s+.*\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|exec(?:ute)?|union|where\s+[\d\w]\s*=\s*[\d\w]|\bor\b\s*[\d\w]\s*=\s*[\d\w])/iu, + /'.*?(?:OR|UNION|SELECT|FROM|WHERE).*?'/iu, // SQL injection + /%27.*?(?:OR|UNION|SELECT|FROM|WHERE).*?(?:%27|')/iu, // URL-encoded SQL injection + /%20(?:OR|UNION|SELECT|FROM|WHERE)%20/iu, // URL-encoded SQL keywords + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=(?:[^&]*\/\/|https?:)/iu, // Open redirect parameters + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=%(?:[^&]*\/\/|https?:)/iu, // URL-encoded open redirect parameters + ]; + + for (const patt of dangerousPatterns) { + if (patt.test(decodedValue)) { + return 'URL contains potentially malicious patterns'; + } + } + + // Port validation (if present) + if (url.port && !/^\d+$/u.test(url.port)) { + return 'Invalid port number'; + } + + return true; + } catch (error) { + return 'Invalid URL format'; + } +}); + +/** + * Keyring validations + */ +export const GetAccountStruct = object({ + accountId: UuidStruct, +}); +export const DeleteAccountStruct = object({ + accountId: UuidStruct, +}); +export const ListAccountAssetsStruct = object({ + accountId: UuidStruct, +}); +export const GetAccountBalancesStruct = object({ + accountId: UuidStruct, + assets: array(CaipAssetTypeStruct), +}); +export const ListAccountTransactionsStruct = object({ + accountId: UuidStruct, + pagination: object({ + limit: integer(), + next: optional(nullable(string())), + }), +}); +export const ExportAccountRequestStruct = object({ + accountId: UuidStruct, + options: optional(ExportAccountOptionsStruct), +}); +export const GetAccounBalancesResponseStruct = record( + CaipAssetTypeStruct, + object({ + amount: PositiveNumberStringStruct, + unit: string(), + }), +); + +export const ListAccountAssetsResponseStruct = array(CaipAssetTypeStruct); + +export const SubmitRequestMethodStruct = enums(Object.values(SolMethod)); + +export const NetworkStruct = enums(Object.values(Network)); + +export const Curenc = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'usd', + 'aed', + 'ars', + 'aud', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'czk', + 'dkk', + 'eur', + 'gbp', + 'gel', + 'hkd', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'krw', + 'kwd', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'php', + 'pkr', + 'pln', + 'rub', + 'sar', + 'sek', + 'sgd', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'zar', + 'xdr', + 'xag', + 'xau', + 'bits', + 'sats', +]); + +export const GetFeeForTransactionParamsStruct = object({ + transaction: string(), + scope: enums(Object.values(Network)), +}); + +export const GetFeeForTransactionResponseStruct = object({ + value: nullable(PositiveNumberStringStruct), +}); + +/** + * Validates if a string is Base58 encoded. + * Base58 uses alphanumeric characters excluding 0, O, I, and l. + */ +export const Base58Struct: Struct = define('Base58', (value) => { + const BASE_58_PATTERN = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/u; + + // First check if it's a string + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + // Then check if it matches the Base58 pattern + if (!BASE_58_PATTERN.test(value)) { + return 'Expected a Base58 encoded string, but received a string with invalid characters'; + } + + return true; +}); + +/** + * Validates if a string is Base64 encoded. + * Base64 uses alphanumeric characters and +, /, and =. + * Empty strings are rejected. + */ +export const Base64Struct = pattern( + string(), + /^(?:[A-Za-z0-9+/]{4})+(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u, +); + +const DERIVATION_PATH_REGEX = /^m\/44'\/501'/u; + +/** + * Validates a Solana derivation path following the format: m/44'/501'/... + */ +export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); + +/** + * Validates an ISO 8601 date string. + */ +export const Iso8601Struct = pattern( + string(), + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/u, +); + +export const SolanaAddressStruct: Struct = define( + 'SolanaAddress', + (value) => { + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + try { + address(value); + return true; + } catch (error) { + return 'Invalid Solana address'; + } + }, +); diff --git a/merged-packages/solana-wallet-snap/src/core/validation/validators.ts b/merged-packages/solana-wallet-snap/src/core/validation/validators.ts new file mode 100644 index 00000000..4084c8c3 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/core/validation/validators.ts @@ -0,0 +1,59 @@ +import { + getJsonError, + InvalidParamsError, + SnapError, + UnauthorizedError, +} from '@metamask/snaps-sdk'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +import { originPermissions } from '../../permissions'; + +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. + * + * @template Params - The expected structure of the request parameters. + * @param requestParams - The request parameters to validate. + * @param struct - The expected structure of the request parameters. + * @throws {typeof InvalidParamsError} If the request parameters do not conform to the expected structure. + */ +export function validateRequest>( + requestParams: Params, + struct: TStruct, +): asserts requestParams is Infer { + try { + assert(requestParams, struct); + } catch (error: any) { + throw new InvalidParamsError(error.message) as unknown as Error; + } +} + +/** + * Validates that the response conforms to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the response. + * @param response - The response to validate. + * @param struct - The expected structure of the response. + * @throws {SnapError} If the response does not conform to the expected structure. + */ +export function validateResponse>( + response: Params, + struct: TStruct, +): asserts response is Infer { + try { + assert(response, struct); + } catch (error) { + throw new SnapError('Invalid Response', { + data: { cause: getJsonError(error) }, + }); + } +} diff --git a/merged-packages/solana-wallet-snap/src/entities/assets.ts b/merged-packages/solana-wallet-snap/src/entities/assets.ts new file mode 100644 index 00000000..06883eaf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/assets.ts @@ -0,0 +1,42 @@ +import type { + NativeCaipAssetType, + Network, + NftCaipAssetType, + TokenCaipAssetType, +} from '../core/constants/solana'; + +export type NativeAsset = { + assetType: NativeCaipAssetType; + keyringAccountId: string; + network: Network; + address: string; + symbol: string; + decimals: number; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type TokenAsset = { + assetType: TokenCaipAssetType; // Using the mint + keyringAccountId: string; + network: Network; + mint: string; + pubkey: string; + symbol: string; + decimals: number; + rawAmount: string; // Without decimals nor multiplier + uiAmount: string; // With decimals and multiplier +}; + +export type NftAsset = { + assetType: NftCaipAssetType; + keyringAccountId: string; + network: Network; + mint: string; + pubkey: string; + symbol: string; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type AssetEntity = NativeAsset | TokenAsset | NftAsset; diff --git a/merged-packages/solana-wallet-snap/src/entities/index.ts b/merged-packages/solana-wallet-snap/src/entities/index.ts new file mode 100644 index 00000000..993847f5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/index.ts @@ -0,0 +1,5 @@ +export type * from './assets'; +export * from './instructions/instructions'; +export * from './keyring-account'; +export * from './subscriptions'; +export * from './transactions'; diff --git a/merged-packages/solana-wallet-snap/src/entities/instructions/index.ts b/merged-packages/solana-wallet-snap/src/entities/instructions/index.ts new file mode 100644 index 00000000..32b452fa --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/instructions/index.ts @@ -0,0 +1 @@ +export * from './instructions'; diff --git a/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.test.ts b/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.test.ts new file mode 100644 index 00000000..943a383f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.test.ts @@ -0,0 +1,154 @@ +import { address } from '@solana/kit'; +import type { Rpc, SolanaRpcApi } from '@solana/kit'; + +import { MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN } from '../../core/services/signer/mocks/scenarios/sendSplToken'; +import { trackError } from '../../core/utils/errors'; +import type { InstructionParseResult } from './instructions'; +import { + extractInstructionsFromUnknownBase64String, + parseInstruction, +} from './instructions'; + +jest.mock('../../core/utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +describe('extractInstructionsFromBase64String', () => { + let mockRpc: Rpc; + + const { + transactionMessageBase64Encoded, + signedTransactionBase64Encoded, + getMultipleAccountsResponse, + } = MOCK_EXECUTION_SCENARIO_SEND_SPL_TOKEN; + + const expectedInstructionParseResults: InstructionParseResult[] = [ + { + type: 'SetComputeUnitLimit', + encoded: { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: new Uint8Array([2, 186, 18, 0, 0]), + dataBase58: 'JqvQNT', + }, + parsed: { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: { + discriminator: 2, + units: 4794, + }, + }, + }, + { + type: 'SetComputeUnitPrice', + encoded: { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: new Uint8Array([3, 232, 3, 0, 0, 0, 0, 0, 0]), + dataBase58: '3tGNFMqHiozw', + }, + parsed: { + programAddress: address('ComputeBudget111111111111111111111111111111'), + data: { + discriminator: 3, + microLamports: 1000n, + }, + }, + }, + { + type: 'Transfer', + encoded: { + programAddress: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + accounts: [ + { + address: address('G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA'), + role: 1, + }, + { + address: address('CSq2wNLSpfKHCdL3E3k1iksbRXWjfnD87b9iy35nL8VP'), + role: 1, + }, + { + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + role: 3, + }, + ], + data: new Uint8Array([3, 232, 3, 0, 0, 0, 0, 0, 0]), + dataBase58: '3tGNFMqHiozw', + }, + parsed: { + programAddress: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + accounts: { + source: { + address: address('G23tQHsbQuh3yqUBoyXDn3TwqEbbbUHAHEeUSvJaVRtA'), + role: 1, + }, + destination: { + address: address('CSq2wNLSpfKHCdL3E3k1iksbRXWjfnD87b9iy35nL8VP'), + role: 1, + }, + authority: { + address: address('BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP'), + role: 3, + }, + }, + data: { + discriminator: 3, + amount: 1000n, + }, + }, + }, + ] as unknown as InstructionParseResult[]; + + beforeEach(() => { + mockRpc = { + getLatestBlockhash: jest.fn().mockReturnValue({ + send: jest.fn(), + }), + getFeeForMessage: jest.fn().mockReturnValue({ + send: jest.fn(), + }), + getMultipleAccounts: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue(getMultipleAccountsResponse?.result), + }), + } as unknown as Rpc; + }); + + it(`successfully extracts instructions from a base64 encoded transaction message`, async () => { + const result = await extractInstructionsFromUnknownBase64String( + mockRpc, + transactionMessageBase64Encoded, + ); + + expect(result).toStrictEqual(expectedInstructionParseResults); + }); + + it(`successfully extracts instructions from a base64 encoded transaction`, async () => { + const result = await extractInstructionsFromUnknownBase64String( + mockRpc, + signedTransactionBase64Encoded, + ); + + expect(result).toStrictEqual(expectedInstructionParseResults); + }); + + it('tracks instruction parsing failures', () => { + const result = parseInstruction({ + programAddress: address('11111111111111111111111111111112'), + accounts: [], + data: new Uint8Array([1, 2, 3]), + }); + + expect(result).toStrictEqual({ + type: 'Unknown', + encoded: expect.objectContaining({ + programAddress: address('11111111111111111111111111111112'), + dataBase58: expect.any(String), + }), + parsed: null, + }); + expect(trackError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Unsupported program address', + }), + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.ts b/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.ts new file mode 100644 index 00000000..23a94a8f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/instructions/instructions.ts @@ -0,0 +1,678 @@ +import type { Infer } from '@metamask/superstruct'; +import { + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ComputeBudgetInstruction, + identifyComputeBudgetInstruction, + parseRequestHeapFrameInstruction, + parseRequestUnitsInstruction, + parseSetComputeUnitLimitInstruction, + parseSetComputeUnitPriceInstruction, + parseSetLoadedAccountsDataSizeLimitInstruction, +} from '@solana-program/compute-budget'; +import { + identifySystemInstruction, + parseAdvanceNonceAccountInstruction, + parseAllocateInstruction, + parseAllocateWithSeedInstruction, + parseAssignInstruction, + parseAssignWithSeedInstruction, + parseAuthorizeNonceAccountInstruction, + parseCreateAccountInstruction, + parseCreateAccountWithSeedInstruction, + parseInitializeNonceAccountInstruction, + parseTransferSolInstruction, + parseTransferSolWithSeedInstruction, + parseUpgradeNonceAccountInstruction, + parseWithdrawNonceAccountInstruction, + SYSTEM_PROGRAM_ADDRESS, + SystemInstruction, +} from '@solana-program/system'; +import { + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + identifyTokenInstruction, + parseAmountToUiAmountInstruction, + parseApproveCheckedInstruction, + parseApproveInstruction, + parseBurnCheckedInstruction, + parseBurnInstruction, + parseCloseAccountInstruction, + parseFreezeAccountInstruction, + parseGetAccountDataSizeInstruction, + parseInitializeAccount2Instruction, + parseInitializeAccount3Instruction, + parseInitializeAccountInstruction, + parseInitializeImmutableOwnerInstruction, + parseInitializeMint2Instruction, + parseInitializeMintInstruction, + parseInitializeMultisig2Instruction, + parseInitializeMultisigInstruction, + parseMintToCheckedInstruction, + parseMintToInstruction, + parseRevokeInstruction, + parseSetAuthorityInstruction, + parseSyncNativeInstruction, + parseThawAccountInstruction, + parseTransferCheckedInstruction, + parseTransferInstruction, + parseUiAmountToAmountInstruction, + TOKEN_PROGRAM_ADDRESS, + TokenInstruction, +} from '@solana-program/token'; +import { + AssociatedTokenInstruction, + identifyAssociatedTokenInstruction as identifyAssociatedToken2022Instruction, + identifyToken2022Instruction, + parseAmountToUiAmountInstruction as parseAmountToUiAmount2022Instruction, + parseApplyConfidentialPendingBalanceInstruction, + parseApproveInstruction as parseApprove2022Instruction, + parseApproveCheckedInstruction as parseApproveChecked2022Instruction, + parseApproveConfidentialTransferAccountInstruction, + parseBurnInstruction as parseBurn2022Instruction, + parseBurnCheckedInstruction as parseBurnChecked2022Instruction, + parseCloseAccountInstruction as parseCloseAccount2022Instruction, + parseConfidentialDepositInstruction, + parseConfidentialTransferInstruction, + parseConfidentialTransferWithFeeInstruction, + parseConfidentialWithdrawInstruction, + parseConfigureConfidentialTransferAccountInstruction, + parseCreateAssociatedTokenIdempotentInstruction, + parseCreateAssociatedTokenInstruction, + parseCreateNativeMintInstruction, + parseDisableConfidentialCreditsInstruction, + parseDisableCpiGuardInstruction, + parseDisableHarvestToMintInstruction, + parseDisableMemoTransfersInstruction, + parseDisableNonConfidentialCreditsInstruction, + parseEmitTokenMetadataInstruction, + parseEmptyConfidentialTransferAccountInstruction, + parseEnableConfidentialCreditsInstruction, + parseEnableCpiGuardInstruction, + parseEnableHarvestToMintInstruction, + parseEnableMemoTransfersInstruction, + parseEnableNonConfidentialCreditsInstruction, + parseFreezeAccountInstruction as parseFreezeAccount2022Instruction, + parseGetAccountDataSizeInstruction as parseGetAccountDataSize2022Instruction, + parseHarvestWithheldTokensToMintForConfidentialTransferFeeInstruction, + parseHarvestWithheldTokensToMintInstruction, + parseInitializeAccountInstruction as parseInitializeAccount2022Instruction, + parseInitializeAccount2Instruction as parseInitializeAccount22022Instruction, + parseInitializeAccount3Instruction as parseInitializeAccount32022Instruction, + parseInitializeConfidentialTransferFeeInstruction, + parseInitializeConfidentialTransferMintInstruction, + parseInitializeDefaultAccountStateInstruction, + parseInitializeGroupMemberPointerInstruction, + parseInitializeGroupPointerInstruction, + parseInitializeImmutableOwnerInstruction as parseInitializeImmutableOwner2022Instruction, + parseInitializeInterestBearingMintInstruction, + parseInitializeMetadataPointerInstruction, + parseInitializeMintInstruction as parseInitializeMint2022Instruction, + parseInitializeMint2Instruction as parseInitializeMint22022Instruction, + parseInitializeMintCloseAuthorityInstruction, + parseInitializeMultisigInstruction as parseInitializeMultisig2022Instruction, + parseInitializeNonTransferableMintInstruction, + parseInitializePausableConfigInstruction, + parseInitializePermanentDelegateInstruction, + parseInitializeScaledUiAmountMintInstruction, + parseInitializeTokenGroupInstruction, + parseInitializeTokenGroupMemberInstruction, + parseInitializeTokenMetadataInstruction, + parseInitializeTransferFeeConfigInstruction, + parseInitializeTransferHookInstruction, + parseMintToInstruction as parseMintTo2022Instruction, + parseMintToCheckedInstruction as parseMintToChecked2022Instruction, + parsePauseInstruction, + parseReallocateInstruction, + parseRecoverNestedAssociatedTokenInstruction, + parseRemoveTokenMetadataKeyInstruction, + parseResumeInstruction, + parseRevokeInstruction as parseRevoke2022Instruction, + parseSetAuthorityInstruction as parseSetAuthority2022Instruction, + parseSetTransferFeeInstruction, + parseSyncNativeInstruction as parseSyncNative2022Instruction, + parseThawAccountInstruction as parseThawAccount2022Instruction, + parseTransferInstruction as parseTransfer2022Instruction, + parseTransferCheckedInstruction as parseTransferChecked2022Instruction, + parseTransferCheckedWithFeeInstruction, + parseUiAmountToAmountInstruction as parseUiAmountToAmount2022Instruction, + parseUpdateConfidentialTransferMintInstruction, + parseUpdateDefaultAccountStateInstruction, + parseUpdateGroupMemberPointerInstruction, + parseUpdateGroupPointerInstruction, + parseUpdateMetadataPointerInstruction, + parseUpdateMultiplierScaledUiMintInstruction, + parseUpdateRateInterestBearingMintInstruction, + parseUpdateTokenGroupMaxSizeInstruction, + parseUpdateTokenGroupUpdateAuthorityInstruction, + parseUpdateTokenMetadataFieldInstruction, + parseUpdateTokenMetadataUpdateAuthorityInstruction, + parseUpdateTransferHookInstruction, + parseWithdrawExcessLamportsInstruction, + parseWithdrawWithheldTokensFromAccountsForConfidentialTransferFeeInstruction, + parseWithdrawWithheldTokensFromAccountsInstruction, + parseWithdrawWithheldTokensFromMintForConfidentialTransferFeeInstruction, + parseWithdrawWithheldTokensFromMintInstruction, + Token2022Instruction, + TOKEN_2022_PROGRAM_ADDRESS, +} from '@solana-program/token-2022'; +import { getBase58Codec } from '@solana/kit'; +import type { Rpc, SolanaRpcApi } from '@solana/kit'; +import type { IInstruction } from '@solana/kit'; + +import { + fromBytesToCompilableTransactionMessage, + fromUnknowBase64StringToTransactionOrTransactionMessage, +} from '../../core/sdk-extensions/codecs'; +import type { + SolanaInstruction, + SolanaTransaction, +} from '../../core/types/solana'; +import { trackError } from '../../core/utils/errors'; +import type { Base64Struct } from '../../core/validation/structs'; +import { + identifySecp256Instruction, + parseVerifySecp256Instruction, + Secp256Instruction, + SECP256K1_PROGRAM_ADDRESS, + SECP256R1_PROGRAM_ADDRESS, +} from './programs/secp256k1-secp256r1'; + +/** + * Truncates the instruction data to 12 characters. + * + * @param data - The instruction data to truncate. + * @returns The truncated instruction data. + */ +export function truncateInstructionData(data: string) { + if (data.length > 12) { + return `${data.slice(0, 5)}...${data.slice(-5)}`; + } + + return data; +} + +// List of all the program addresses that we support parsing instructions for +type ParseableInstructionProgramAddress = + | typeof SYSTEM_PROGRAM_ADDRESS + | typeof COMPUTE_BUDGET_PROGRAM_ADDRESS + | typeof TOKEN_PROGRAM_ADDRESS + | typeof TOKEN_2022_PROGRAM_ADDRESS + | typeof ASSOCIATED_TOKEN_PROGRAM_ADDRESS; + +/** + * Union type that represents all the instruction types that we support parsing: + * "CreateAccount" | "Assign" | "TransferSol" | ... + */ +type InstructionType = + | keyof typeof SystemInstruction + | keyof typeof ComputeBudgetInstruction + | keyof typeof TokenInstruction + | keyof typeof Token2022Instruction + | keyof typeof AssociatedTokenInstruction; + +type ParsedInstruction = { + data: Record; + accounts?: Record< + string, + { + address: string; + role: number; + } + >; +}; + +type EncodedInstruction = Omit & { + dataBase58: string; +}; + +// When the instruction is parsed successfully, we return data with this structure +export type InstructionParseSuccess = { + type: InstructionType; + encoded: EncodedInstruction; + parsed: ParsedInstruction; +}; + +// When the instruction cannot be parsed, we return data with this structure +export type InstructionParseFailure = { + type: 'Unknown'; + encoded: EncodedInstruction; + parsed: null; +}; + +export type InstructionParseResult = + | InstructionParseSuccess + | InstructionParseFailure; + +type ParsingConfig = { + /** The function that, given an instruction, returns its type (e.g. "InitializeMint") */ + identifier: (instruction: IInstruction) => TInstructionType; + /** The enum that holds every instruction type for a given program */ + instructionEnum: Record; + /** Maps every instruction type (e.g. "InitializeMint") to the function that parses it */ + typeToParserMap: Record< + TInstructionType, + (instruction: IInstruction) => ParsedInstruction + >; +}; + +// Structure that maps every supported program address to its parsing config +const programAddressToParsingConfig: Record< + ParseableInstructionProgramAddress, + ParsingConfig +> = { + [SYSTEM_PROGRAM_ADDRESS]: { + identifier: identifySystemInstruction, + instructionEnum: SystemInstruction, + typeToParserMap: { + [SystemInstruction.Assign]: parseAssignInstruction, + [SystemInstruction.TransferSol]: parseTransferSolInstruction, + [SystemInstruction.CreateAccount]: parseCreateAccountInstruction, + [SystemInstruction.CreateAccountWithSeed]: + parseCreateAccountWithSeedInstruction, + [SystemInstruction.AdvanceNonceAccount]: + parseAdvanceNonceAccountInstruction, + [SystemInstruction.WithdrawNonceAccount]: + parseWithdrawNonceAccountInstruction, + [SystemInstruction.InitializeNonceAccount]: + parseInitializeNonceAccountInstruction, + [SystemInstruction.AuthorizeNonceAccount]: + parseAuthorizeNonceAccountInstruction, + [SystemInstruction.Allocate]: parseAllocateInstruction, + [SystemInstruction.AllocateWithSeed]: parseAllocateWithSeedInstruction, + [SystemInstruction.AssignWithSeed]: parseAssignWithSeedInstruction, + [SystemInstruction.TransferSolWithSeed]: + parseTransferSolWithSeedInstruction, + [SystemInstruction.UpgradeNonceAccount]: + parseUpgradeNonceAccountInstruction, + }, + }, + [COMPUTE_BUDGET_PROGRAM_ADDRESS]: { + identifier: identifyComputeBudgetInstruction, + instructionEnum: ComputeBudgetInstruction, + typeToParserMap: { + [ComputeBudgetInstruction.RequestUnits]: parseRequestUnitsInstruction, + [ComputeBudgetInstruction.RequestHeapFrame]: + parseRequestHeapFrameInstruction, + [ComputeBudgetInstruction.SetComputeUnitPrice]: + parseSetComputeUnitPriceInstruction, + [ComputeBudgetInstruction.SetComputeUnitLimit]: + parseSetComputeUnitLimitInstruction, + [ComputeBudgetInstruction.SetLoadedAccountsDataSizeLimit]: + parseSetLoadedAccountsDataSizeLimitInstruction, + }, + }, + [TOKEN_PROGRAM_ADDRESS]: { + identifier: identifyTokenInstruction, + instructionEnum: TokenInstruction, + typeToParserMap: { + [TokenInstruction.InitializeMint]: parseInitializeMintInstruction, + [TokenInstruction.InitializeAccount]: parseInitializeAccountInstruction, + [TokenInstruction.InitializeMultisig]: parseInitializeMultisigInstruction, + [TokenInstruction.Transfer]: parseTransferInstruction, + [TokenInstruction.Approve]: parseApproveInstruction, + [TokenInstruction.Revoke]: parseRevokeInstruction, + [TokenInstruction.SetAuthority]: parseSetAuthorityInstruction, + [TokenInstruction.MintTo]: parseMintToInstruction, + [TokenInstruction.Burn]: parseBurnInstruction, + [TokenInstruction.CloseAccount]: parseCloseAccountInstruction, + [TokenInstruction.FreezeAccount]: parseFreezeAccountInstruction, + [TokenInstruction.ThawAccount]: parseThawAccountInstruction, + [TokenInstruction.TransferChecked]: parseTransferCheckedInstruction, + [TokenInstruction.ApproveChecked]: parseApproveCheckedInstruction, + [TokenInstruction.MintToChecked]: parseMintToCheckedInstruction, + [TokenInstruction.BurnChecked]: parseBurnCheckedInstruction, + [TokenInstruction.InitializeAccount2]: parseInitializeAccount2Instruction, + [TokenInstruction.SyncNative]: parseSyncNativeInstruction, + [TokenInstruction.InitializeAccount3]: parseInitializeAccount3Instruction, + [TokenInstruction.InitializeMultisig2]: + parseInitializeMultisig2Instruction, + [TokenInstruction.InitializeMint2]: parseInitializeMint2Instruction, + [TokenInstruction.GetAccountDataSize]: parseGetAccountDataSizeInstruction, + [TokenInstruction.InitializeImmutableOwner]: + parseInitializeImmutableOwnerInstruction, + [TokenInstruction.AmountToUiAmount]: parseAmountToUiAmountInstruction, + [TokenInstruction.UiAmountToAmount]: parseUiAmountToAmountInstruction, + }, + }, + [TOKEN_2022_PROGRAM_ADDRESS]: { + identifier: identifyToken2022Instruction, + instructionEnum: Token2022Instruction, + typeToParserMap: { + [Token2022Instruction.InitializeMint]: parseInitializeMint2022Instruction, + [Token2022Instruction.InitializeAccount]: + parseInitializeAccount2022Instruction, + [Token2022Instruction.InitializeMultisig]: + parseInitializeMultisig2022Instruction, + [Token2022Instruction.Transfer]: parseTransfer2022Instruction, + [Token2022Instruction.Approve]: parseApprove2022Instruction, + [Token2022Instruction.Revoke]: parseRevoke2022Instruction, + [Token2022Instruction.SetAuthority]: parseSetAuthority2022Instruction, + [Token2022Instruction.MintTo]: parseMintTo2022Instruction, + [Token2022Instruction.Burn]: parseBurn2022Instruction, + [Token2022Instruction.CloseAccount]: parseCloseAccount2022Instruction, + [Token2022Instruction.FreezeAccount]: parseFreezeAccount2022Instruction, + [Token2022Instruction.ThawAccount]: parseThawAccount2022Instruction, + [Token2022Instruction.TransferChecked]: + parseTransferChecked2022Instruction, + [Token2022Instruction.ApproveChecked]: parseApproveChecked2022Instruction, + [Token2022Instruction.MintToChecked]: parseMintToChecked2022Instruction, + [Token2022Instruction.BurnChecked]: parseBurnChecked2022Instruction, + [Token2022Instruction.InitializeAccount2]: + parseInitializeAccount22022Instruction, + [Token2022Instruction.SyncNative]: parseSyncNative2022Instruction, + [Token2022Instruction.InitializeAccount3]: + parseInitializeAccount32022Instruction, + [Token2022Instruction.InitializeMultisig2]: + parseInitializeMultisig2022Instruction, + [Token2022Instruction.InitializeMint2]: + parseInitializeMint22022Instruction, + [Token2022Instruction.GetAccountDataSize]: + parseGetAccountDataSize2022Instruction, + [Token2022Instruction.InitializeImmutableOwner]: + parseInitializeImmutableOwner2022Instruction, + [Token2022Instruction.AmountToUiAmount]: + parseAmountToUiAmount2022Instruction, + [Token2022Instruction.UiAmountToAmount]: + parseUiAmountToAmount2022Instruction, + [Token2022Instruction.InitializeMintCloseAuthority]: + parseInitializeMintCloseAuthorityInstruction, + [Token2022Instruction.InitializeTransferFeeConfig]: + parseInitializeTransferFeeConfigInstruction, + [Token2022Instruction.TransferCheckedWithFee]: + parseTransferCheckedWithFeeInstruction, + [Token2022Instruction.WithdrawWithheldTokensFromMint]: + parseWithdrawWithheldTokensFromMintInstruction, + [Token2022Instruction.WithdrawWithheldTokensFromAccounts]: + parseWithdrawWithheldTokensFromAccountsInstruction, + [Token2022Instruction.HarvestWithheldTokensToMint]: + parseHarvestWithheldTokensToMintInstruction, + [Token2022Instruction.SetTransferFee]: parseSetTransferFeeInstruction, + [Token2022Instruction.InitializeConfidentialTransferMint]: + parseInitializeConfidentialTransferMintInstruction, + [Token2022Instruction.UpdateConfidentialTransferMint]: + parseUpdateConfidentialTransferMintInstruction, + [Token2022Instruction.ConfigureConfidentialTransferAccount]: + parseConfigureConfidentialTransferAccountInstruction, + [Token2022Instruction.ApproveConfidentialTransferAccount]: + parseApproveConfidentialTransferAccountInstruction, + [Token2022Instruction.EmptyConfidentialTransferAccount]: + parseEmptyConfidentialTransferAccountInstruction, + [Token2022Instruction.ConfidentialDeposit]: + parseConfidentialDepositInstruction, + [Token2022Instruction.ConfidentialWithdraw]: + parseConfidentialWithdrawInstruction, + [Token2022Instruction.ConfidentialTransfer]: + parseConfidentialTransferInstruction, + [Token2022Instruction.ApplyConfidentialPendingBalance]: + parseApplyConfidentialPendingBalanceInstruction, + [Token2022Instruction.EnableConfidentialCredits]: + parseEnableConfidentialCreditsInstruction, + [Token2022Instruction.DisableConfidentialCredits]: + parseDisableConfidentialCreditsInstruction, + [Token2022Instruction.EnableNonConfidentialCredits]: + parseEnableNonConfidentialCreditsInstruction, + [Token2022Instruction.DisableNonConfidentialCredits]: + parseDisableNonConfidentialCreditsInstruction, + [Token2022Instruction.ConfidentialTransferWithFee]: + parseConfidentialTransferWithFeeInstruction, + [Token2022Instruction.InitializeDefaultAccountState]: + parseInitializeDefaultAccountStateInstruction, + [Token2022Instruction.UpdateDefaultAccountState]: + parseUpdateDefaultAccountStateInstruction, + [Token2022Instruction.Reallocate]: parseReallocateInstruction, + [Token2022Instruction.EnableMemoTransfers]: + parseEnableMemoTransfersInstruction, + [Token2022Instruction.DisableMemoTransfers]: + parseDisableMemoTransfersInstruction, + [Token2022Instruction.CreateNativeMint]: parseCreateNativeMintInstruction, + [Token2022Instruction.InitializeNonTransferableMint]: + parseInitializeNonTransferableMintInstruction, + [Token2022Instruction.InitializeInterestBearingMint]: + parseInitializeInterestBearingMintInstruction, + [Token2022Instruction.UpdateRateInterestBearingMint]: + parseUpdateRateInterestBearingMintInstruction, + [Token2022Instruction.EnableCpiGuard]: parseEnableCpiGuardInstruction, + [Token2022Instruction.DisableCpiGuard]: parseDisableCpiGuardInstruction, + [Token2022Instruction.InitializePermanentDelegate]: + parseInitializePermanentDelegateInstruction, + [Token2022Instruction.InitializeTransferHook]: + parseInitializeTransferHookInstruction, + [Token2022Instruction.UpdateTransferHook]: + parseUpdateTransferHookInstruction, + [Token2022Instruction.InitializeConfidentialTransferFee]: + parseInitializeConfidentialTransferFeeInstruction, + [Token2022Instruction.WithdrawWithheldTokensFromMintForConfidentialTransferFee]: + parseWithdrawWithheldTokensFromMintForConfidentialTransferFeeInstruction, + [Token2022Instruction.WithdrawWithheldTokensFromAccountsForConfidentialTransferFee]: + parseWithdrawWithheldTokensFromAccountsForConfidentialTransferFeeInstruction, + [Token2022Instruction.HarvestWithheldTokensToMintForConfidentialTransferFee]: + parseHarvestWithheldTokensToMintForConfidentialTransferFeeInstruction, + [Token2022Instruction.EnableHarvestToMint]: + parseEnableHarvestToMintInstruction, + [Token2022Instruction.DisableHarvestToMint]: + parseDisableHarvestToMintInstruction, + [Token2022Instruction.WithdrawExcessLamports]: + parseWithdrawExcessLamportsInstruction, + [Token2022Instruction.InitializeMetadataPointer]: + parseInitializeMetadataPointerInstruction, + [Token2022Instruction.UpdateMetadataPointer]: + parseUpdateMetadataPointerInstruction, + [Token2022Instruction.InitializeGroupPointer]: + parseInitializeGroupPointerInstruction, + [Token2022Instruction.UpdateGroupPointer]: + parseUpdateGroupPointerInstruction, + [Token2022Instruction.InitializeGroupMemberPointer]: + parseInitializeGroupMemberPointerInstruction, + [Token2022Instruction.UpdateGroupMemberPointer]: + parseUpdateGroupMemberPointerInstruction, + [Token2022Instruction.InitializeScaledUiAmountMint]: + parseInitializeScaledUiAmountMintInstruction, + [Token2022Instruction.UpdateMultiplierScaledUiMint]: + parseUpdateMultiplierScaledUiMintInstruction, + [Token2022Instruction.InitializePausableConfig]: + parseInitializePausableConfigInstruction, + [Token2022Instruction.Pause]: parsePauseInstruction, + [Token2022Instruction.Resume]: parseResumeInstruction, + [Token2022Instruction.InitializeTokenMetadata]: + parseInitializeTokenMetadataInstruction, + [Token2022Instruction.UpdateTokenMetadataField]: + parseUpdateTokenMetadataFieldInstruction, + [Token2022Instruction.RemoveTokenMetadataKey]: + parseRemoveTokenMetadataKeyInstruction, + [Token2022Instruction.UpdateTokenMetadataUpdateAuthority]: + parseUpdateTokenMetadataUpdateAuthorityInstruction, + [Token2022Instruction.EmitTokenMetadata]: + parseEmitTokenMetadataInstruction, + [Token2022Instruction.InitializeTokenGroup]: + parseInitializeTokenGroupInstruction, + [Token2022Instruction.UpdateTokenGroupMaxSize]: + parseUpdateTokenGroupMaxSizeInstruction, + [Token2022Instruction.UpdateTokenGroupUpdateAuthority]: + parseUpdateTokenGroupUpdateAuthorityInstruction, + [Token2022Instruction.InitializeTokenGroupMember]: + parseInitializeTokenGroupMemberInstruction, + }, + }, + [ASSOCIATED_TOKEN_PROGRAM_ADDRESS]: { + identifier: identifyAssociatedToken2022Instruction, + instructionEnum: AssociatedTokenInstruction, + typeToParserMap: { + [AssociatedTokenInstruction.CreateAssociatedToken]: + parseCreateAssociatedTokenInstruction, + [AssociatedTokenInstruction.CreateAssociatedTokenIdempotent]: + parseCreateAssociatedTokenIdempotentInstruction, + [AssociatedTokenInstruction.RecoverNestedAssociatedToken]: + parseRecoverNestedAssociatedTokenInstruction, + }, + }, + [SECP256K1_PROGRAM_ADDRESS]: { + identifier: identifySecp256Instruction, + instructionEnum: Secp256Instruction, + typeToParserMap: { + [Secp256Instruction.Verify]: parseVerifySecp256Instruction, + }, + }, + [SECP256R1_PROGRAM_ADDRESS]: { + identifier: identifySecp256Instruction, + instructionEnum: Secp256Instruction, + typeToParserMap: { + [Secp256Instruction.Verify]: parseVerifySecp256Instruction, + }, + }, +}; + +/** + * Parses a generic instruction, returning the parsed instruction if it's supported, + * or the original non-parsed instruction if it's not. + * + * @example + * ```ts + * const instruction = { + * programAddress: address('ComputeBudget111111111111111111111111111111'), + * data: new Uint8Array([3, 232, 3, 0, 0, 0, 0, 0, 0]), + * dataBase58: '3tGNFMqHiozw', + * } + * const programAddress = address('ComputeBudget111111111111111111111111111111'); + * const parsedInstruction = parseInstruction(instruction, programAddress); + * + * // Returns: + * { + * type: 'SetComputeUnitPrice', + * encoded: { + * programAddress: address('ComputeBudget111111111111111111111111111111'), + * data: new Uint8Array([3, 232, 3, 0, 0, 0, 0, 0, 0]), + * dataBase58: '3tGNFMqHiozw', + * }, + * parsed: { + * programAddress: address('ComputeBudget111111111111111111111111111111'), + * data: { + * discriminator: 3, + * microLamports: 1000n, + * }, + * }, + * } + * ``` + * @param instruction - The instruction to parse. + * @returns The result of the instruction parsing. + */ +export const parseInstruction = ( + instruction: IInstruction, +): InstructionParseResult => { + const { programAddress } = instruction; + + // We need to rewrite the data field from ReadonlyUint8Array to Uint8Array + // because the interface context is serialized as JSON, and JSON does not support ReadonlyUint8Array + const encoded = { + ...instruction, + dataBase58: getBase58Codec().decode(instruction.data ?? new Uint8Array()), + }; + + try { + const parsingConfig: ParsingConfig = + // @ts-expect-error - we check if parsingConfig exists right after + programAddressToParsingConfig[programAddress]; + + if (!parsingConfig) { + throw new Error('Unsupported program address'); + } + + const { identifier, typeToParserMap, instructionEnum } = parsingConfig; + const instructionType = identifier(instruction); + const type = instructionEnum[ + instructionType as unknown as number + ] as InstructionType; + const parser = typeToParserMap[instructionType]; + + if (!parser) { + throw new Error('Unsupported instruction type'); + } + + const parsed = parser(instruction); + + return { + type, + encoded, + parsed, + }; + } catch (error) { + /* eslint-disable-next-line no-void */ + void trackError(error); + + return { + type: 'Unknown', + encoded, + parsed: null, + }; + } +}; + +/** + * Extracts the instructions from a base64 string, adapting the logic depending on whether + * the string represents a transaction or a transaction message. + * + * @param rpc - The RPC to use to extract the instructions. + * @param base64EncodedString - The base64 encoded string to extract the instructions from. + * @returns The instructions from the base64 encoded string. + * @throws If the base64 encoded string is not a valid transaction or transaction message. + */ +export const extractInstructionsFromUnknownBase64String = async ( + rpc: Rpc, + base64EncodedString: Infer, +): Promise => { + const transactionOrTransactionMessage = + await fromUnknowBase64StringToTransactionOrTransactionMessage( + base64EncodedString, + rpc, + ); + + const transactionMessage = + 'instructions' in transactionOrTransactionMessage + ? transactionOrTransactionMessage + : await fromBytesToCompilableTransactionMessage( + transactionOrTransactionMessage.messageBytes, + rpc, + ); + + const { instructions } = transactionMessage; + return instructions.map(parseInstruction); +}; + +/** + * Converts a SolanaInstruction to an IInstruction that we can parse with `parseInstruction` + * + * @param instruction - The Solana instruction to convert. + * @param transactionData - The full transaction data. + * @returns The IInstruction. + */ +export const toIInstruction = ( + instruction: SolanaInstruction, + transactionData: SolanaTransaction, +): IInstruction => { + // Filter to only keep the account indexes available in the `accountKeys` + const isInAccountKeys = (accountIndex: number) => + accountIndex < transactionData.transaction.message.accountKeys.length; + + // Build the accounts array + const accounts = instruction.accounts + .filter(isInAccountKeys) + .map((accountIndex) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + address: transactionData.transaction.message.accountKeys[accountIndex]!, // The non-null assertion is safe because we filtered the indexes above + role: 0, + })); + + const programAddress = + transactionData.transaction.message.accountKeys[instruction.programIdIndex]; + + if (!programAddress) { + throw new Error('Program address not found'); + } + + // Build the IInstruction object + const iInstruction = { + accounts, + data: getBase58Codec().encode(instruction.data), + programAddress, + } as unknown as IInstruction; + + return iInstruction; +}; diff --git a/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.test.ts b/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.test.ts new file mode 100644 index 00000000..fd27b848 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.test.ts @@ -0,0 +1,183 @@ +import type { IInstruction } from '@solana/kit'; +import { getBase58Codec } from '@solana/kit'; + +import { + identifySecp256Instruction, + isSecp256k1Instruction, + isSecp256r1Instruction, + parseVerifySecp256Instruction, + Secp256Instruction, + SECP256K1_PROGRAM_ADDRESS, + SECP256R1_PROGRAM_ADDRESS, +} from './secp256k1-secp256r1'; + +describe('secp256k1-secp256r1', () => { + describe('identifySecp256Instruction', () => { + it('identifies the instruction', () => { + const instruction = { + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0]), + }; + + const identifiedInstruction = identifySecp256Instruction(instruction); + + expect(identifiedInstruction).toStrictEqual(Secp256Instruction.Verify); + }); + }); + + describe('isSecp256k1Instruction', () => { + it('returns true for secp256k1 instruction', () => { + const instruction = { + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: new Uint8Array([0]), + }; + + const result = isSecp256k1Instruction(instruction); + + expect(result).toBe(true); + }); + + it('returns false for secp256r1 instruction', () => { + const instruction = { + programAddress: SECP256R1_PROGRAM_ADDRESS, + data: new Uint8Array([0]), + }; + + const result = isSecp256k1Instruction(instruction); + + expect(result).toBe(false); + }); + + it('returns false for other instruction', () => { + const instruction = { + programAddress: 'SomeOtherProgramAddress', + data: new Uint8Array([0]), + } as unknown as IInstruction; + + const result = isSecp256k1Instruction(instruction); + + expect(result).toBe(false); + }); + }); + + describe('isSecp256r1Instruction', () => { + it('returns true for secp256r1 instruction', () => { + const instruction = { + programAddress: SECP256R1_PROGRAM_ADDRESS, + data: new Uint8Array([0]), + }; + + const result = isSecp256r1Instruction(instruction); + + expect(result).toBe(true); + }); + + it('returns false for secp256k1 instruction', () => { + const instruction = { + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: new Uint8Array([0]), + }; + + const result = isSecp256r1Instruction(instruction); + + expect(result).toBe(false); + }); + + it('returns false for other instruction', () => { + const instruction = { + programAddress: 'SomeOtherProgramAddress', + data: new Uint8Array([0]), + } as unknown as IInstruction; + + const result = isSecp256r1Instruction(instruction); + + expect(result).toBe(false); + }); + }); + + describe('parseVerifySecp256Instruction', () => { + it('parses an empty secp256k1 instruction', () => { + const instruction = { + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0]), + }; + + const parsedInstruction = parseVerifySecp256Instruction(instruction); + + expect(parsedInstruction).toStrictEqual({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: { numSignatures: 0 }, + }); + }); + + it('parses an empty secp256r1 instruction', () => { + const instruction = { + programAddress: SECP256R1_PROGRAM_ADDRESS, + data: new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0]), + }; + + const parsedInstruction = parseVerifySecp256Instruction(instruction); + + expect(parsedInstruction).toStrictEqual({ + programAddress: SECP256R1_PROGRAM_ADDRESS, + data: { numSignatures: 0 }, + }); + }); + + it('parses a real-world secp256k1 instruction', () => { + /** + * Taken from this transaction: https://solscan.io/tx/h6CgrypQ1WQcpSCxdF1ot8ghhHwdvZ2aEDq4T5D7uYgbrTsyzh2FcmUmduXpW1EJ1VMYnMxFwVNC8DE85rz8i7e + * It contains 3 secp256k1 signatures. + */ + const instructionBase58 = + '4GitRX1TrUudSfhNjqnrEr8moSWnpf8JFgUcETEVr9zj8N1tJEPFtwkyAX2mNSF2QUAmPB8tajRgfUWBnrjL3E9yLLX9S3iN91LpR2YtR95G8Gxhydrx7fJAdUmxnz2DDvmSNSJUFFJGi7KwwFPohJK5PjAVBgiP1GjUEiFjWP7KDjWeak7qkKT7P2wGssiMd28Zdn5awtA931LWjkQ2iNf8wK6AQfpGhQqnBgmeq1PZm5SmqdvANfdrJzN1iMPLUoUgrNde8WgVSDx9RQmhbnwLeK6zbSxGUx48AUBCfXV7EncCGz5HfbyGqKjNzE52Jecv2E2U6vacEcqauESgULLWn88wvWa9Zq4nL4KFXNivrDZT6PunaM7LSe6nkdtvdLExitw1vomz7XRxXZWzSy4HKhGKL4ZzEDgRuQy2y1iGvvXwq5j1Wa'; + + const bytes = getBase58Codec().encode(instructionBase58); + + const parsedInstruction = parseVerifySecp256Instruction({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: bytes, + }); + + expect(parsedInstruction).toStrictEqual({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: { numSignatures: 3 }, + }); + }); + + it('parses a real-world secp256r1 instruction', () => { + /** + * Taken from this transaction: https://solscan.io/tx/2CDp2XYu3KsfhKnCmbPKoqxfF34ootxr2L8TFf5cqCZVXbotSyQB2SRbiEu3gsBceZwq9D6hFXCBhhEAUerqELXp + * It contains 3 secp256r1 signatures. + */ + const instructionBase58 = + '37bVnf5k5XNTVwdBpMaj56HaSdM5wjEsUcWkSNab2fVyUdekdyaxYJcFq2GatwnuQyTUMMB3GWTF7gmVv9CiKbw3HGUX1868ghSuq7FdpKb2v7UHPBhEfpANQ4dithun9HJCsfmnZpcD16xTt8FL8ZvVugd76arLFRqEiUfXEqVt1M96QSiw5gAei2L8e43bLW1USz98ABchXNPFS4fqUxuoHq37Kp7RHnQgsB6CFbYVYKPqFaksnV5j'; + + const bytes = getBase58Codec().encode(instructionBase58); + + const parsedInstruction = parseVerifySecp256Instruction({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: bytes, + }); + + expect(parsedInstruction).toStrictEqual({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: { numSignatures: 1 }, + }); + }); + + it('parses instruction with different number of signatures', () => { + const instruction = { + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: new Uint8Array([5, 0, 0, 0, 0, 0, 0, 0, 0]), + }; + + const parsedInstruction = parseVerifySecp256Instruction(instruction); + + expect(parsedInstruction).toStrictEqual({ + programAddress: SECP256K1_PROGRAM_ADDRESS, + data: { numSignatures: 5 }, + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.ts b/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.ts new file mode 100644 index 00000000..5bb493cc --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/instructions/programs/secp256k1-secp256r1.ts @@ -0,0 +1,80 @@ +import type { Codec, IInstruction, ReadonlyUint8Array } from '@solana/kit'; +import { address, getStructCodec, getU8Codec } from '@solana/kit'; + +/** + * Utilities for parsing secp256k1 and secp256r1 program instructions. + * + * Unfortunately, Solana doesn't provide a JS/TS client for secp256k1 and secp256r1 programs, unlike other programs, + * for instance: + * + * @see https://github.com/solana-program/token-2022/blob/main/clients/js/src/generated/instructions/createAssociatedToken.ts + * @see https://github.com/solana-program/compute-budget/blob/main/clients/js/src/generated/instructions/setComputeUnitLimit.ts + * + * So we have to create our own utilities. + * NOTE: This implementation intentionally simplifies parsing by extracting only the first byte + * of the instruction data, which represents the `numSignatures` field. This is currently the only + * field required for our use case. The remainder of the instruction data is not parsed, as it is + * complex and unnecessary for our current needs. + * + * This simplification enables us to share identifiers, codecs, types, and parsers between both programs. + * If full parsing is ever required, separate implementations for each program should be created. + * @see https://docs.rs/solana-secp256k1-program/latest/solana_secp256k1_program/ + * @see https://docs.rs/solana-secp256r1-program/latest/solana_secp256r1_program/ + * @see https://github.com/search?q=repo%3Aanza-xyz%2Fagave%20secp256k1&type=code + * @see https://github.com/search?q=repo%3Aanza-xyz%2Fagave%20secp256r1&type=code + */ + +export const SECP256K1_PROGRAM_ADDRESS = address( + 'KeccakSecp256k11111111111111111111111111111', +); + +export const SECP256R1_PROGRAM_ADDRESS = address( + 'Secp256r1SigVerify1111111111111111111111111', +); + +export enum Secp256Instruction { + Verify = 0, +} + +export const identifySecp256Instruction = ( + _instruction: + | { + data: ReadonlyUint8Array; + } + | ReadonlyUint8Array, +) => Secp256Instruction.Verify; + +export const isSecp256k1Instruction = (instruction: IInstruction) => + instruction.programAddress === SECP256K1_PROGRAM_ADDRESS; + +export const isSecp256r1Instruction = (instruction: IInstruction) => + instruction.programAddress === SECP256R1_PROGRAM_ADDRESS; + +type Secp256InstructionData = { + numSignatures: number; + /** There is actually more data after numSignatures */ +}; + +const getSecp256InstructionCodec = (): Codec => + getStructCodec([ + ['numSignatures', getU8Codec()], // This is the first byte of the data + /** There is actually more data after this byte */ + ]); + +type ParsedVerifySecp256Instruction = { + readonly programAddress: string; + data: Secp256InstructionData; +}; + +export type ParsedSecp256Instruction = { + instructionType: Secp256Instruction.Verify; +} & ParsedVerifySecp256Instruction; + +export const parseVerifySecp256Instruction = (instruction: IInstruction) => { + return { + programAddress: instruction.programAddress, + data: getSecp256InstructionCodec().decode( + instruction.data ?? new Uint8Array(), + ), + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/entities/keyring-account.ts b/merged-packages/solana-wallet-snap/src/entities/keyring-account.ts new file mode 100644 index 00000000..68b1c859 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/keyring-account.ts @@ -0,0 +1,31 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; + +/** + * We need to store the index of the KeyringAccount in the state because + * we want to be able to restore any account with a previously used index. + */ +export type SolanaKeyringAccount = { + entropySource: EntropySourceId; + derivationPath: `m/${string}`; + index: number; +} & KeyringAccount; + +/** + * Converts a Solana keyring account to its stricter form (required by the Keyring API). + * + * @param account - A Solana keyring account. + * @returns A strict keyring account (with no additional fields). + */ +export function asStrictKeyringAccount( + account: SolanaKeyringAccount, +): KeyringAccount { + const { type, id, address, options, methods, scopes } = account; + return { + type, + id, + address, + options, + methods, + scopes, + }; +} diff --git a/merged-packages/solana-wallet-snap/src/entities/subscriptions.ts b/merged-packages/solana-wallet-snap/src/entities/subscriptions.ts new file mode 100644 index 00000000..64748a7d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/subscriptions.ts @@ -0,0 +1,191 @@ +import type { GetWebSocketsResult } from '@metamask/snaps-sdk'; +import type { JsonRpcParams } from '@metamask/utils'; +import type { SolanaRpcResponse } from '@solana/kit'; + +import type { Network } from '../core/constants/solana'; +import type { Serializable } from '../core/serialization/types'; + +export type WebSocketConnection = GetWebSocketsResult[number] & { + readonly network: Network; +}; + +export type SubscribeMethod = + | 'accountSubscribe' + | 'programSubscribe' + | 'signatureSubscribe'; + +export const subscribeMethodToUnsubscribeMethod: Record< + SubscribeMethod, + string +> = { + accountSubscribe: 'accountUnsubscribe', + programSubscribe: 'programUnsubscribe', + signatureSubscribe: 'signatureUnsubscribe', +}; + +/** + * A request to subscribe to a JSON-RPC subscription. + */ +export type SubscriptionRequest = { + method: SubscribeMethod; + params: JsonRpcParams; + network: Network; + expiryMilliseconds?: number; // Number of milliseconds after which the subscription will expire + metadata?: { + [key: string]: Serializable; + }; +}; + +/** + * Once the Subscriber acknowledges the subscription request, + * it generates a subscription ID, and the subscription is pending (waiting for the confirmation message). + */ +export type PendingSubscription = SubscriptionRequest & { + readonly id: string; + readonly status: 'pending'; + readonly requestId: string; // Same a the field `id` + readonly createdAt: string; // ISO string + readonly expiresAt?: string; // ISO string +}; + +/** + * A message that we receive from the RPC WebSocket server after a subscription request, + * that confirms that the subscription was successfully established. + */ +export type SubscriptionConfirmation = { + jsonrpc: string; + id: string | number; + result: number; +}; + +// After server confirms the subscription +export type ConfirmedSubscription = Omit & { + readonly status: 'confirmed'; + readonly rpcSubscriptionId: number; // Server's confirmation ID + readonly confirmedAt: string; // ISO string +}; + +// Union type for all states +export type Subscription = PendingSubscription | ConfirmedSubscription; + +/** + * A message that we receive from the RPC WebSocket server after a subscription request, + * that confirms that the subscription was successfully established. + */ +export type UnsubscriptionConfirmation = { + jsonrpc: string; + id: string | number; + result: true; +}; + +/** + * A message that we receive from the RPC WebSocket server after subscribing to + * `accountSubscribe`, notifying us that the account has changed. + */ +export type AccountNotification = { + jsonrpc: string; + method: 'accountNotification'; + params: { + subscription: number; + result: { + context: { + slot: number; + }; + value: { + data: object; + executable: boolean; + lamports: number; + owner: string; + rentEpoch: number | null; + }; + }; + }; +}; + +/** + * A message that we receive from the RPC WebSocket server after subscribing to + * `programSubscribe`, notifying us that the program has changed. + */ +export type ProgramNotification = { + jsonrpc: string; + method: 'programNotification'; + params: { + subscription: number; + result: { + context: { + slot: number; + }; + value: { + pubkey: string; + account: { + data: { + parsed: { + info: { + isNative: boolean; + mint: string; + owner: string; + state: string; + tokenAmount: { + amount: string; + decimals: number; + uiAmount: number; + uiAmountString: string; + }; + }; + type: string; + }; + program: string; + space: number; + }; + executable: boolean; + lamports: number; + owner: string; + rentEpoch: number | null; + }; + }; + }; + }; +}; + +export type SignatureNotification = { + jsonrpc: string; + method: 'signatureNotification'; + params: { + subscription: number; + result: SolanaRpcResponse<{ + // eslint-disable-next-line id-denylist + err: null | object; + }>; + }; +}; + +export type Notification = + | AccountNotification + | ProgramNotification + | SignatureNotification; + +export type AccountNotificationHandler = ( + notification: AccountNotification, + subscription: Subscription, +) => Promise; + +export type ProgramNotificationHandler = ( + notification: ProgramNotification, + subscription: Subscription, +) => Promise; + +export type SignatureNotificationHandler = ( + notification: SignatureNotification, + subscription: Subscription, +) => Promise; + +/** + * A callback that will be called when a notification is received. + * For instance, if the subscription is for the `accountSubscribe` method, and we receive a `accountNotification`, the callback will be called. + */ +export type NotificationHandler = + | AccountNotificationHandler + | ProgramNotificationHandler + | SignatureNotificationHandler; + +export type ConnectionRecoveryHandler = (network: Network) => Promise; diff --git a/merged-packages/solana-wallet-snap/src/entities/transactions.ts b/merged-packages/solana-wallet-snap/src/entities/transactions.ts new file mode 100644 index 00000000..dbc9de9f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/entities/transactions.ts @@ -0,0 +1,5 @@ +import { enums } from '@metamask/superstruct'; + +export type Commitment = 'processed' | 'confirmed' | 'finalized'; + +export const CommitmentStruct = enums(['processed', 'confirmed', 'finalized']); diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/Advanced/Advanced.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/Advanced/Advanced.tsx new file mode 100644 index 00000000..386877d2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/Advanced/Advanced.tsx @@ -0,0 +1,48 @@ +import { Box, Button, Icon } from '@metamask/snaps-sdk/jsx'; + +import type { Network } from '../../../../core/constants/solana'; +import type { Locale } from '../../../../core/utils/i18n'; +import { i18n } from '../../../../core/utils/i18n'; +import type { InstructionParseResult } from '../../../../entities'; +import { ConfirmSignAndSendTransactionFormNames } from '../../views/ConfirmTransactionRequest/events'; +import { Instruction } from '../Instruction/Instruction'; + +type AdvancedProps = { + instructions: InstructionParseResult[]; + showInstructions: boolean; + locale: Locale; + scope: Network; +}; + +export const Advanced = ({ + instructions, + showInstructions, + locale, + scope, +}: AdvancedProps) => { + const translate = i18n(locale); + + const showInstructionsMode = showInstructions ? 'hide' : 'show'; + const instructionsModeIcon = showInstructions ? 'arrow-up' : 'arrow-down'; + + return ( + + + {showInstructions && ( + + {instructions.map((instruction) => ( + + ))} + + )} + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/AssetChange/AssetChange.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/AssetChange/AssetChange.tsx new file mode 100644 index 00000000..390641f4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/AssetChange/AssetChange.tsx @@ -0,0 +1,45 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Image, Text } from '@metamask/snaps-sdk/jsx'; + +import type { TransactionScanAssetChange } from '../../../../core/services/transaction-scan/types'; +import type { Preferences } from '../../../../core/types/snap'; +import { formatCryptoBalance } from '../../../../core/utils/formatCryptoBalance'; +import { formatFiat } from '../../../../core/utils/formatFiat'; + +export const AssetChange: SnapComponent<{ + asset: TransactionScanAssetChange; + preferences: Preferences; +}> = ({ asset, preferences }) => { + const changeType = asset.type; + + return ( + + + + {changeType === 'in' ? '+' : '-'} + {formatCryptoBalance(asset.value ?? 0, preferences.locale)} + + {asset.logo ? ( + + + + ) : null} + {asset.symbol ?? ''} + + {asset.price ? ( + + {formatFiat( + asset.price.toString(), + preferences.currency, + preferences.locale, + )} + + ) : null} + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/BasicNullableField/BasicNullableField.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/BasicNullableField/BasicNullableField.tsx new file mode 100644 index 00000000..4863095f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/BasicNullableField/BasicNullableField.tsx @@ -0,0 +1,17 @@ +import { Box, Text } from '@metamask/snaps-sdk/jsx'; + +export const BasicNullableField = ({ + label, + value, +}: { + label: string; + value?: string | null | undefined; +}) => + value !== null && value !== undefined ? ( + + + {label} + + {value} + + ) : null; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChanges.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChanges.tsx new file mode 100644 index 00000000..172b3ee0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChanges.tsx @@ -0,0 +1,98 @@ +import { Box, Section, Text } from '@metamask/snaps-sdk/jsx'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import type { + TransactionScanEstimatedChanges, + TransactionScanStatus, +} from '../../../../core/services/transaction-scan/types'; +import type { FetchStatus, Preferences } from '../../../../core/types/snap'; +import { i18n } from '../../../../core/utils/i18n'; +import { AssetChange } from '../AssetChange/AssetChange'; +import { EstimatedChangesHeader } from './EstimatedChangesHeader'; +import { EstimatedChangesSkeleton } from './EstimatedChangesSkeleton'; + +type EstimatedChangesProps = { + changes: TransactionScanEstimatedChanges | null; + scanStatus: TransactionScanStatus | null; + preferences: Preferences; + scanFetchStatus: FetchStatus; +}; + +export const EstimatedChanges: SnapComponent = ({ + changes, + preferences, + scanFetchStatus, + scanStatus, +}) => { + const translate = i18n(preferences.locale); + + const isFetching = scanFetchStatus === 'fetching'; + const isFetched = scanFetchStatus === 'fetched'; + const isError = scanFetchStatus === 'error'; + + if (isFetching) { + return ; + } + + if (isError || (isFetched && scanStatus === 'ERROR')) { + return ( +
+ + + {translate('confirmation.estimatedChanges.notAvailable')} + +
+ ); + } + + const send = changes?.assets.filter((asset) => asset.type === 'out') ?? []; + const receive = changes?.assets.filter((asset) => asset.type === 'in') ?? []; + + const hasChanges = send.length > 0 || receive.length > 0; + + if (isFetched && !hasChanges) { + return ( +
+ + + {translate('confirmation.estimatedChanges.noChanges')} + +
+ ); + } + + return ( +
+ + {send?.length > 0 ? ( + + + {translate('confirmation.estimatedChanges.send')} + + + + {send?.map((asset) => ( + + ))} + + + + ) : null} + {null} + {receive?.length > 0 ? ( + + + {translate('confirmation.estimatedChanges.receive')} + + + + {receive?.map((asset) => ( + + ))} + + + + ) : null} +
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesHeader.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesHeader.tsx new file mode 100644 index 00000000..5d1965de --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesHeader.tsx @@ -0,0 +1,22 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Icon, Text, Tooltip, Box } from '@metamask/snaps-sdk/jsx'; + +import type { Preferences } from '../../../../core/types/snap'; +import { i18n } from '../../../../core/utils/i18n'; + +export const EstimatedChangesHeader: SnapComponent<{ + preferences: Preferences; +}> = ({ preferences }) => { + const translate = i18n(preferences.locale); + + return ( + + + {translate('confirmation.estimatedChanges')} + + + + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesSkeleton.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesSkeleton.tsx new file mode 100644 index 00000000..3821024f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/EstimatedChanges/EstimatedChangesSkeleton.tsx @@ -0,0 +1,24 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Section, Skeleton } from '@metamask/snaps-sdk/jsx'; + +import type { Preferences } from '../../../../core/types/snap'; +import { EstimatedChangesHeader } from './EstimatedChangesHeader'; + +export const EstimatedChangesSkeleton: SnapComponent<{ + preferences: Preferences; +}> = ({ preferences }) => { + return ( +
+ + + + + + + + + + +
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/Instruction/Instruction.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/Instruction/Instruction.tsx new file mode 100644 index 00000000..854d639f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/Instruction/Instruction.tsx @@ -0,0 +1,112 @@ +import { Address, Box, Link, Section, Text } from '@metamask/snaps-sdk/jsx'; +import { isAddress } from '@solana/kit'; + +import type { Network } from '../../../../core/constants/solana'; +import { deserialize } from '../../../../core/serialization/deserialize'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import { getSolanaExplorerUrl } from '../../../../core/utils/getSolanaExplorerUrl'; +import type { Locale } from '../../../../core/utils/i18n'; +import { i18n } from '../../../../core/utils/i18n'; +import type { InstructionParseResult } from '../../../../entities'; +import { truncateInstructionData } from '../../../../entities'; + +type InstructionProps = { + instruction: InstructionParseResult; + scope: Network; + locale: Locale; +}; + +/** + * Format the instruction type to a human readable string, + * by splitting a PascalCase string into words, + * and capitalizing the first letter of each word. + * + * @example + * ```ts + * formatInstructionType('InitializeMint') // 'Initialize Mint' + * ``` + * @param type - The instruction type. + * @returns The formatted instruction type. + */ +const fromPascalCaseToCapitalizedWords = (type: string): string => { + return type + .replace(/([A-Z])/gu, ' $1') + .trim() + .replace(/^./u, (str) => str.toUpperCase()); +}; + +const LinkToExplorer = ({ + address, + scope, +}: { + address: string; + scope: Network; +}) => ( + +
+ +); + +export const Instruction = ({ + instruction, + scope, + locale, +}: InstructionProps) => { + const translate = i18n(locale); + const { type, encoded, parsed } = instruction; + const { programAddress, dataBase58 } = encoded; + const isParseSuccess = parsed !== null; + const typeFormatted = fromPascalCaseToCapitalizedWords(type); + + return ( +
+ + {typeFormatted} + + + + {translate('confirmation.advanced.programId')} + + + + {isParseSuccess && + Object.entries(parsed.accounts ?? {}).map(([key, value]) => ( + + + {fromPascalCaseToCapitalizedWords(key)} + + {isAddress(value.address) ? ( + + ) : ( + {deserialize(value.address)?.toString() ?? ''} + )} + + ))} + {isParseSuccess && + Object.entries(parsed.data).map(([key, value]) => ( + + + {fromPascalCaseToCapitalizedWords(key)} + + {isAddress(value) ? ( + + ) : ( + {deserialize(value)?.toString() ?? ''} + )} + + ))} + {!isParseSuccess && ( + + + {translate('confirmation.advanced.data')} + + {truncateInstructionData(dataBase58)} + + )} +
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/TransactionAlert.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/TransactionAlert.tsx new file mode 100644 index 00000000..3f03b85d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/TransactionAlert.tsx @@ -0,0 +1,115 @@ +import { Banner, Box, Icon, Link, Text } from '@metamask/snaps-sdk/jsx'; +import type { BannerProps, SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import type { + TransactionScanError, + TransactionScanValidation, +} from '../../../../core/services/transaction-scan/types'; +import type { FetchStatus, Preferences } from '../../../../core/types/snap'; +import { i18n } from '../../../../core/utils/i18n'; +import { getErrorMessage } from './getErrorMessage'; + +type TransactionAlertProps = { + preferences: Preferences; + validation: TransactionScanValidation | null; + error: TransactionScanError | null; + scanFetchStatus: FetchStatus; +}; + +const VALIDATION_TYPE_TO_SEVERITY: Partial< + Record< + NonNullable, + BannerProps['severity'] + > +> = { + Malicious: 'danger', + Warning: 'warning', +}; + +export const TransactionAlert: SnapComponent = ({ + preferences, + validation, + error, + scanFetchStatus, +}) => { + const translate = i18n(preferences.locale); + + /** + * Displays a warning banner if the transaction scan fails. + */ + if (scanFetchStatus === 'error') { + return ( + + {translate('send.simulationMessageAPIError')} + + ); + } + + /** + * Displays nothing if there is no error or validation. + */ + if (!error && !validation) { + return {null}; + } + + /** + * Displays a warning banner if the transaction scan fails. + */ + if (error) { + return ( + + + {translate('confirmation.simulationErrorSubtitle', { + reason: getErrorMessage(error, preferences), + })} + + + ); + } + + /** + * Displays nothing if there is no validation. + */ + if (!validation) { + return {null}; + } + + const severity = validation?.type + ? VALIDATION_TYPE_TO_SEVERITY[validation.type] + : undefined; + + /** + * Displays a banner if the validation there is a validation. + */ + if (severity) { + return ( + + {translate('confirmation.validationErrorSubtitle')} + + + {translate('confirmation.validationErrorLearnMore')} + + + + {' '} + {translate('confirmation.validationErrorSecurityAdviced')}{' '} + Blockaid + + + ); + } + + /** + * Displays nothing by default. + */ + return {null}; +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/getErrorMessage.ts b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/getErrorMessage.ts new file mode 100644 index 00000000..9fa88746 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/getErrorMessage.ts @@ -0,0 +1,34 @@ +import type { TransactionScanError } from '../../../../core/services/transaction-scan/types'; +import type { Preferences } from '../../../../core/types/snap'; +import { i18n } from '../../../../core/utils/i18n'; + +/** + * Maps error codes to user-friendly messages + */ +const ERROR_MESSAGES: Record = { + AccountAlreadyInUse: 'transactionScan.errors.accountAlreadyInUse', + ResultWithNegativeLamports: 'transactionScan.errors.insufficientSol', + SlippageToleranceExceeded: 'transactionScan.errors.slippageToleranceExceeded', // Jupiter + ExceededDesiredSlippageLimit: + 'transactionScan.errors.slippageToleranceExceeded', // Raydium +}; + +/** + * Gets a user-friendly message from a transaction scan error. + * + * @param error - The error of the transaction scan. + * @param preferences - The user preferences containing locale information. + * @returns A user-friendly error message, or the original error code if no mapping exists. + */ +export function getErrorMessage( + error: TransactionScanError, + preferences: Preferences, +): string { + const translate = i18n(preferences.locale); + const { code } = error; + + const translationKey = + (code && ERROR_MESSAGES[code]) ?? 'transactionScan.errors.unknownError'; + + return translate(translationKey as keyof typeof translate); +} diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/index.ts b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/index.ts new file mode 100644 index 00000000..829e45fe --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionAlert/index.ts @@ -0,0 +1 @@ +export { TransactionAlert } from './TransactionAlert'; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionDetails/TransactionDetails.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionDetails/TransactionDetails.tsx new file mode 100644 index 00000000..a444619a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/components/TransactionDetails/TransactionDetails.tsx @@ -0,0 +1,169 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Address, + Box, + Icon, + Image, + Section, + Skeleton, + Text, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { Domain } from '../../../../core/components/Domain/Domain'; +import type { Network } from '../../../../core/constants/solana'; +import { METAMASK_ORIGIN, Networks } from '../../../../core/constants/solana'; +import type { FetchStatus, Preferences } from '../../../../core/types/snap'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import { formatCrypto } from '../../../../core/utils/formatCrypto'; +import { formatFiat } from '../../../../core/utils/formatFiat'; +import { i18n } from '../../../../core/utils/i18n'; +import { parseOrigin } from '../../../../core/utils/parseOrigin'; +import { tokenToFiat } from '../../../../core/utils/tokenToFiat'; + +type TransactionDetailsProps = { + accountAddress: string | null; + accountDomain: string | null; + destinationAddress: string | null; + destinationDomain: string | null; + scope: Network; + feeInSol: string | null; + nativePrice: number | null; + fetchingPricesStatus: FetchStatus; + preferences: Preferences; + networkImage: string | null; + origin: string; +}; + +export const TransactionDetails: SnapComponent = ({ + origin, + accountAddress, + accountDomain, + destinationAddress, + destinationDomain, + scope, + feeInSol, + nativePrice, + fetchingPricesStatus, + preferences, + networkImage, +}) => { + const { currency, locale } = preferences; + const translate = i18n(locale); + const isMetaMaskOrigin = origin === METAMASK_ORIGIN; + const originHostname = + origin && !isMetaMaskOrigin ? parseOrigin(origin) : null; + + const pricesFetching = fetchingPricesStatus === 'fetching'; + const pricesError = fetchingPricesStatus === 'error'; + + const feeInFiat = + feeInSol && nativePrice && !pricesError + ? formatFiat(tokenToFiat(feeInSol, nativePrice), currency, locale) + : ''; + + return ( +
+ {originHostname ? ( + + + + + {translate('confirmation.origin')} + + + + + + {originHostname} + + {null} + + ) : null} + + + {translate('confirmation.account')} + + {accountDomain && accountAddress ? ( + + ) : null} + {!accountDomain && accountAddress ? ( +
+ ) : null} + + {destinationAddress ? ( + + {null} + + + {translate('confirmation.recipient')} + + {destinationDomain ? ( + + ) : ( +
+ )} + + + ) : null} + {null} + + + {translate('confirmation.network')} + + + + + + {Networks[scope].name} + + + {null} + + + {translate('confirmation.fee')} + + {feeInSol ? ( + + {pricesFetching ? ( + + ) : ( + {feeInFiat} + )} + + {formatCrypto( + feeInSol, + Networks[scope].nativeToken.symbol, + locale, + )} + + + ) : ( + + + + )} + +
+ ); +}; + +export default TransactionDetails; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/utils/extractDestinationAddress.ts b/merged-packages/solana-wallet-snap/src/features/confirmation/utils/extractDestinationAddress.ts new file mode 100644 index 00000000..a68d7b0a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/utils/extractDestinationAddress.ts @@ -0,0 +1,36 @@ +import type { InstructionParseResult } from '../../../entities'; + +const TRANSFER_INSTRUCTION_TYPES = new Set([ + 'TransferSol', + 'TransferSolWithSeed', + 'Transfer', + 'TransferChecked', + 'TransferCheckedWithFee', +]); + +/** + * Extracts the destination address from parsed transaction instructions + * by finding the first transfer-type instruction and reading its destination account. + * + * @param instructions - The parsed instructions from the transaction. + * @returns The destination address if found, or null. + */ +export function extractDestinationAddress( + instructions: InstructionParseResult[], +): string | null { + for (const instruction of instructions) { + if ( + !instruction.parsed || + !TRANSFER_INSTRUCTION_TYPES.has(instruction.type) + ) { + continue; + } + + const destination = instruction.parsed.accounts?.destination; + if (destination?.address) { + return destination.address; + } + } + + return null; +} diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/ConfirmSignIn.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/ConfirmSignIn.tsx new file mode 100644 index 00000000..c4383402 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/ConfirmSignIn.tsx @@ -0,0 +1,248 @@ +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Image, + Row, + Section, + Text, +} from '@metamask/snaps-sdk/jsx'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { Domain } from '../../../../core/components/Domain/Domain'; +import { Networks } from '../../../../core/constants/solana'; +import type { Network } from '../../../../core/constants/solana'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import type { Preferences } from '../../../../core/types/snap'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import { i18n } from '../../../../core/utils/i18n'; +import { parseOrigin } from '../../../../core/utils/parseOrigin'; +import type { SolanaKeyringAccount } from '../../../../entities'; +import { BasicNullableField } from '../../components/BasicNullableField/BasicNullableField'; +import { EstimatedChanges } from '../../components/EstimatedChanges/EstimatedChanges'; +import { ConfirmSignInFormNames } from './events'; + +export type ConfirmSignInProps = { + params: Partial<{ + domain: string; + address: string; + statement: string; + uri: string; + version: string; + chainId: string; + nonce: string; + issuedAt: string; + expirationTime: string; + notBefore: string; + requestId: string; + resources: string[]; + }>; + origin: string; + account: SolanaKeyringAccount; + accountDomain: string | null; + scope: Network; + preferences: Preferences; + networkImage: string | null; +}; + +export const ConfirmSignIn: SnapComponent = ({ + params, + origin, + account, + accountDomain, + scope, + preferences, + networkImage, +}) => { + const translate = i18n(preferences.locale); + const originHostname = origin ? parseOrigin(origin) : null; + + const { + domain, + statement, + uri, + version, + chainId, + nonce, + issuedAt, + expirationTime, + notBefore, + requestId, + resources, + address, + } = params; + + // The inputs are already sanitized by the struct validation + // No need to sanitize again here + const accountAddressCaip10 = addressToCaip10(scope, account.address); + const signInAddressCaip10 = address ? addressToCaip10(scope, address) : null; + + const isBadAccount = signInAddressCaip10 !== accountAddressCaip10; + const isBadDomain = domain !== originHostname; + + return ( + + + + {null} + {translate('confirmation.signIn.title')} + + {translate('confirmation.signIn.description')} + + + + {preferences.simulateOnChainActions ? ( + + ) : null} + +
+ {originHostname ? ( + + {originHostname} + + ) : null} + + + {domain ?? translate('confirmation.signIn.unknownDomain')} + + + + + {accountDomain ? ( + + ) : ( +
+ )} + +
+ +
+ + {translate('confirmation.signIn.message')} + + {statement ?? ''} + + + + + + {translate('confirmation.network')} + + + + + + {Networks[scope].name} + + + + {signInAddressCaip10 ? ( + + + {translate('confirmation.account')} + +
+ + ) : null} + + + + + + + + + + {resources && resources.length > 0 ? ( + + + {translate('confirmation.signIn.resources')} + + + {resources.map((resource) => ( + {resource} + ))} + + + ) : null} +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/events.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/events.tsx new file mode 100644 index 00000000..6da3d6b2 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/events.tsx @@ -0,0 +1,31 @@ +import { resolveInterface } from '../../../../core/utils/interface'; + +/** + * Handles the click event for the cancel button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onCancelButtonClick({ id }: { id: string }) { + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onConfirmButtonClick({ id }: { id: string }) { + await resolveInterface(id, true); +} + +export enum ConfirmSignInFormNames { + Cancel = 'confirm-sign-in-cancel', + Confirm = 'confirm-sign-in-confirm', +} + +export const eventHandlers = { + [ConfirmSignInFormNames.Cancel]: onCancelButtonClick, + [ConfirmSignInFormNames.Confirm]: onConfirmButtonClick, +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.test.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.test.tsx new file mode 100644 index 00000000..84ad8435 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.test.tsx @@ -0,0 +1,85 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { installSnap } from '@metamask/snaps-jest'; + +import { Network } from '../../../../core/constants/solana'; +import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; +import { MOCK_SIGN_IN_REQUEST } from '../../../../core/services/wallet/mocks'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../../core/test/mocks/solana-keyring-accounts'; +import type { MockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { startMockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { TEST_ORIGIN } from '../../../../core/test/utils'; +import type { Preferences } from '../../../../core/types/snap'; +import type { ConfirmSignInProps } from './ConfirmSignIn'; +import { ConfirmSignIn } from './ConfirmSignIn'; + +describe('render', () => { + let mockSolanaRpc: MockSolanaRpc; + + beforeAll(() => { + mockSolanaRpc = startMockSolanaRpc(); + }); + + afterAll(() => { + mockSolanaRpc.shutdown(); + }); + + it('renders the confirmation dialog', async () => { + const initialState = { + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: MOCK_SOLANA_KEYRING_ACCOUNT_0, + }, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const { onKeyringRequest } = await installSnap({ + options: { + ...mockPreferences, + unencryptedState: initialState, + }, + }); + + const requestId = globalThis.crypto.randomUUID(); + + const request: SolanaKeyringRequest = { + id: requestId, + scope: Network.Testnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + request: MOCK_SIGN_IN_REQUEST, + origin: TEST_ORIGIN, + }; + + const response = onKeyringRequest({ + origin: TEST_ORIGIN, + method: KeyringRpcMethod.SubmitRequest, + params: request as unknown as KeyringRequest, + }); + + const screen = await (response as any).getInterface(); + + expect(screen).toRender( + , + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.tsx new file mode 100644 index 00000000..5dd19f6f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignIn/render.tsx @@ -0,0 +1,56 @@ +import { assert } from '@metamask/superstruct'; + +import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; +import { SolanaSignInRequestStruct } from '../../../../core/services/wallet/structs'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { + createInterface, + getPreferences, + showDialog, +} from '../../../../core/utils/interface'; +import type { SolanaKeyringAccount } from '../../../../entities'; +import { nameResolutionService } from '../../../../snapContext'; +import type { ConfirmSignInProps } from './ConfirmSignIn'; +import { ConfirmSignIn } from './ConfirmSignIn'; + +/** + * Renders the confirmation dialog for a sign in request. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns The confirmation dialog. + */ +export async function render( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, +) { + assert(request.request, SolanaSignInRequestStruct); + + const { + request: { params }, + scope, + origin, + } = request; + + const [preferences, accountDomain] = await Promise.all([ + getPreferences(), + nameResolutionService.resolveAddress(scope, account.address), + ]); + + const id = await createInterface( + , + {}, + ); + + const dialogPromise = showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx new file mode 100644 index 00000000..bf445ca8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -0,0 +1,122 @@ +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { Domain } from '../../../../core/components/Domain/Domain'; +import { Networks } from '../../../../core/constants/solana'; +import type { Network } from '../../../../core/constants/solana'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import type { Locale } from '../../../../core/utils/i18n'; +import { i18n } from '../../../../core/utils/i18n'; +import { parseOrigin } from '../../../../core/utils/parseOrigin'; +import type { SolanaKeyringAccount } from '../../../../entities'; +import { ConfirmSignMessageFormNames } from './events'; + +export type ConfirmSignMessageProps = { + message: string; + account: SolanaKeyringAccount; + accountDomain: string | null; + scope: Network; + locale: Locale; + networkImage: string | null; + origin: string; +}; + +export const ConfirmSignMessage: SnapComponent = ({ + message, + account, + accountDomain, + scope, + locale, + networkImage, + origin, +}) => { + const translate = i18n(locale); + const { address } = account; + const originHostname = origin ? parseOrigin(origin) : null; + const addressCaip10 = addressToCaip10(scope, address); + + return ( + + + + {null} + + {translate('confirmation.signMessage.title')} + + + +
+ + + {translate('confirmation.signMessage.message')} + + + + {message} + +
+ +
+ {originHostname ? ( + + + + {translate('confirmation.origin')} + + + + + + {originHostname} + + ) : null} + + + {translate('confirmation.account')} + + {accountDomain ? ( + + ) : ( +
+ )} + + + + {translate('confirmation.network')} + + + + + + {Networks[scope].name} + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/events.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/events.tsx new file mode 100644 index 00000000..80825329 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/events.tsx @@ -0,0 +1,31 @@ +import { resolveInterface } from '../../../../core/utils/interface'; + +/** + * Handles the click event for the cancel button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onCancelButtonClick({ id }: { id: string }) { + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onConfirmButtonClick({ id }: { id: string }) { + await resolveInterface(id, true); +} + +export enum ConfirmSignMessageFormNames { + Cancel = 'confirm-sign-message-cancel', + Confirm = 'confirm-sign-message-confirm', +} + +export const eventHandlers = { + [ConfirmSignMessageFormNames.Cancel]: onCancelButtonClick, + [ConfirmSignMessageFormNames.Confirm]: onConfirmButtonClick, +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.test.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.test.tsx new file mode 100644 index 00000000..c691f2f8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.test.tsx @@ -0,0 +1,82 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { installSnap } from '@metamask/snaps-jest'; + +import { Network } from '../../../../core/constants/solana'; +import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; +import { MOCK_SIGN_MESSAGE_REQUEST } from '../../../../core/services/wallet/mocks'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../../core/test/mocks/solana-keyring-accounts'; +import type { MockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { startMockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { TEST_ORIGIN } from '../../../../core/test/utils'; +import type { Preferences } from '../../../../core/types/snap'; +import { ConfirmSignMessage } from './ConfirmSignMessage'; + +describe('render', () => { + let mockSolanaRpc: MockSolanaRpc; + + beforeAll(() => { + mockSolanaRpc = startMockSolanaRpc(); + }); + + afterAll(() => { + mockSolanaRpc.shutdown(); + }); + + it('renders the confirmation dialog', async () => { + const initialState = { + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: MOCK_SOLANA_KEYRING_ACCOUNT_0, + }, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const { onKeyringRequest } = await installSnap({ + options: { + ...mockPreferences, + unencryptedState: initialState, + }, + }); + + const request: SolanaKeyringRequest = { + id: globalThis.crypto.randomUUID(), + scope: Network.Testnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + request: MOCK_SIGN_MESSAGE_REQUEST, + origin: TEST_ORIGIN, + }; + + const response = onKeyringRequest({ + origin: TEST_ORIGIN, + method: KeyringRpcMethod.SubmitRequest, + params: request as unknown as KeyringRequest, + }); + + const screen = await (response as any).getInterface(); + + expect(screen).toRender( + , + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx new file mode 100644 index 00000000..285fedfd --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx @@ -0,0 +1,64 @@ +import { assert } from '@metamask/superstruct'; +import { getBase64Codec, getUtf8Codec } from '@solana/kit'; + +import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; +import { SolanaSignMessageRequestStruct } from '../../../../core/services/wallet/structs'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { FALLBACK_LANGUAGE } from '../../../../core/utils/i18n'; +import { + createInterface, + getPreferences, + showDialog, +} from '../../../../core/utils/interface'; +import type { SolanaKeyringAccount } from '../../../../entities'; +import { nameResolutionService } from '../../../../snapContext'; +import { ConfirmSignMessage } from './ConfirmSignMessage'; + +/** + * Renders the confirmation dialog for a sign message. + * + * @param request - The request to confirm. + * @param account - The account that the request is for. + * @returns The confirmation dialog. + */ +export async function render( + request: SolanaKeyringRequest, + account: SolanaKeyringAccount, +) { + assert(request.request, SolanaSignMessageRequestStruct); + + const { + request: { + params: { message: messageBase64 }, + }, + scope, + origin, + } = request; + + const messageBytes = getBase64Codec().encode(messageBase64); + const messageUtf8 = getUtf8Codec().decode(messageBytes); + + const [locale, accountDomain] = await Promise.all([ + getPreferences() + .then((preferences) => preferences.locale) + .catch(() => FALLBACK_LANGUAGE), + nameResolutionService.resolveAddress(scope, account.address), + ]); + + const id = await createInterface( + , + {}, + ); + + const dialogPromise = showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx new file mode 100644 index 00000000..edae5378 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -0,0 +1,91 @@ +import { + Box, + Button, + Container, + Footer, + Heading, +} from '@metamask/snaps-sdk/jsx'; + +import { Networks } from '../../../../core/constants/solana'; +import { i18n } from '../../../../core/utils/i18n'; +import { Advanced } from '../../components/Advanced/Advanced'; +import { EstimatedChanges } from '../../components/EstimatedChanges/EstimatedChanges'; +import { TransactionAlert } from '../../components/TransactionAlert'; +import { TransactionDetails } from '../../components/TransactionDetails/TransactionDetails'; +import { ConfirmSignAndSendTransactionFormNames } from './events'; +import type { ConfirmTransactionRequestContext } from './types'; + +export const ConfirmTransactionRequest = ({ + context, +}: { + context: ConfirmTransactionRequestContext; +}) => { + const translate = i18n(context.preferences.locale); + + const feeInSol = context.feeEstimatedInSol; + const { nativeToken } = Networks[context.scope]; + const nativePrice = context.tokenPrices[nativeToken.caip19Id]?.price ?? null; + + const shouldDisableConfirmButton = + context.scanFetchStatus === 'fetching' || context.scan?.status === 'ERROR'; + + return ( + + + {context.preferences.useSecurityAlerts ? ( + + ) : null} + + {null} + + {translate(`confirmation.${context.method}.title`)} + + {null} + + {context.preferences.simulateOnChainActions ? ( + + ) : null} + + + +
+ + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/events.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/events.tsx new file mode 100644 index 00000000..2fae2d11 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/events.tsx @@ -0,0 +1,76 @@ +import { + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME, + resolveInterface, + updateInterface, +} from '../../../../core/utils/interface'; +import { state } from '../../../../snapContext'; +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from './types'; + +/** + * Handles the click event for the show advanced button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + * @param params.context - The current confirmation context. + */ +async function onShowAdvancedButtonClick({ + id, + context, +}: { + id: string; + context: ConfirmTransactionRequestContext; +}) { + const updatedContext: ConfirmTransactionRequestContext = { + ...context, + advanced: { + ...context.advanced, + shown: !context.advanced.shown, + }, + }; + + await updateInterface( + id, + , + updatedContext, + ); +} + +/** + * Handles the click event for the cancel button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onCancelButtonClick({ id }: { id: string }) { + await resolveInterface(id, false); + await state.deleteKey( + `mapInterfaceNameToId.${CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME}`, + ); +} + +/** + * Handles the click event for the confirm button. + * + * @param params - The parameters for the function. + * @param params.id - The ID of the interface to update. + */ +async function onConfirmButtonClick({ id }: { id: string }) { + await resolveInterface(id, true); + await state.deleteKey( + `mapInterfaceNameToId.${CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME}`, + ); +} + +export enum ConfirmSignAndSendTransactionFormNames { + ShowAdvanced = 'confirm-sign-and-send-transaction-show-advanced', + Cancel = 'confirm-sign-and-send-transaction-cancel', + Confirm = 'confirm-sign-and-send-transaction-confirm', +} + +export const eventHandlers = { + [ConfirmSignAndSendTransactionFormNames.ShowAdvanced]: + onShowAdvancedButtonClick, + [ConfirmSignAndSendTransactionFormNames.Cancel]: onCancelButtonClick, + [ConfirmSignAndSendTransactionFormNames.Confirm]: onConfirmButtonClick, +}; diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.test.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.test.tsx new file mode 100644 index 00000000..38460595 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.test.tsx @@ -0,0 +1,414 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { KeyringRpcMethod, SolMethod } from '@metamask/keyring-api'; +import { installSnap } from '@metamask/snaps-jest'; + +import { MOCK_SPOT_PRICES } from '../../../../core/clients/price-api/mocks/spot-prices'; +import { KnownCaip19Id, Network } from '../../../../core/constants/solana'; +import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; +import { + MOCK_SOLANA_RPC_GET_FEE_FOR_MESSAGE_RESPONSE, + MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE, + MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_SWAP_RESPONSE, +} from '../../../../core/services/mocks/mockSolanaRpcResponses'; +import { + MOCK_SCAN_TRANSACTION_RESPONSE, + MOCK_SECURITY_ALERTS_API_SCAN_TRANSACTIONS_RESPONSE, +} from '../../../../core/services/mocks/scanResponses'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../../../core/test/mocks/solana-keyring-accounts'; +import type { MockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { startMockSolanaRpc } from '../../../../core/test/mocks/startMockSolanaRpc'; +import { TEST_ORIGIN } from '../../../../core/test/utils'; +import type { Preferences } from '../../../../core/types/snap'; +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import { DEFAULT_CONFIRMATION_CONTEXT } from './render'; +import type { ConfirmTransactionRequestContext } from './types'; + +// FIXME: OnKeyringRequest doesn't let us test the confirmation dialog +describe('render', () => { + let mockSolanaRpc: MockSolanaRpc; + + const mockSpotPrices = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + [KnownCaip19Id.SolMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + }; + + const mockConfirmationContext: ConfirmTransactionRequestContext = { + ...DEFAULT_CONFIRMATION_CONTEXT, + scope: Network.Localnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + accountDomain: null, + transaction: + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAOGL90BPMeQxbCdwSbyC2lv/FG3wE/28MLN5GTUYRikvRD9kaGxPJKAoVLt6tV3mRDIMC64Ke2ttBthAfrnxYfDdJtjNxrVLYBP6VBwAW4QcrJODCTq4A0YurnmfI8K4w2eCOqjtLeJ1tGDigOcQ8vJrW8+5B/z3Osuht6LeNXKcYNN2e6UMKyu+PAm4cbix+Ajv4ojwZExmKpP/WVAUXmKrlPhsyBl8xubS/1QIgSYyG36OJXLzlDdk+evw3cLVQ78K0R5qT8KUSk+oJRvvgVQm4b+yjGtmRmd2B8atn1ZqZG9LLuzSr6EAfhR656lu+lF3wZL99DU/P4pl1hT7Ny4wCelyAZtp+XbpgUSfBu/NxYkBd5hXf1v05A7SOMOsV0uXEKjinavFTZPsjPLDavr0sb7T6a8SBqKIPML6T3oq5jKD0N0oI1T+8K47DiJ9N82JyiZvsX3fj3y3zO++Tr3FUGp9UXGMd0yShWY5hpHV62i164o5tLbVxzVVshAAAAAF8Be7pvj7sWgKQq4pHYnNv3ary6SldAXJ09pCa+2ikkAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WfZGzkJtUYvjI9Pg4Deh+Wb4xlObZV4AA9qrzYUv8qaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPtD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egBVuD2k2Zaz0TbFWi/F1uqUYnLl/XS/ztlXSu2/W0YsDqGXmnuD1SAyrz2Y1fk3C8Y1Y1Fwep0ifs3I9l5PHKmBqfVFxksXFEhjMlMPUrxf1ja7gibof1E49vZigAAAACs8TbrAfwcTog9I8i1hEq1mjf2at1XxemsO1PgWdNcZFyu+IFzMmg0dnWJuhEhMR2EMUEW+6TUQ3iKu5tJiClIBwoCCwwJABhYqNqaifwADQAFAlWtBAANAAkDkNADAAAAAAAOBgABAA8QEQEBDgYAAgAdEBEBARItEQADARIPEhMSHhgeGRoDAh0fGx4AEREgHgQFHBIUFQYPBwgBABARFhcUAgkdJ+UXy5d6460qAgAAACZkAAExZAECQEIPAAAAAAAj6YIgLwAAADIAABEDAgAAAQkBGgWCEnkMjW1w1PFgdAevgQo46hGC/KyyBA4xfviukUQF7PTn8uoEB3btfg==', + scan: MOCK_SCAN_TRANSACTION_RESPONSE, + feeEstimatedInSol: '0.000082', + tokenPrices: mockSpotPrices, + tokenPricesFetchStatus: 'fetched', + scanFetchStatus: 'fetched', + origin: TEST_ORIGIN, + }; + + beforeAll(() => { + mockSolanaRpc = startMockSolanaRpc(); + }); + + afterAll(() => { + mockSolanaRpc.shutdown(); + }); + + describe('when all preferences are enabled', () => { + it('renders the confirmation dialog', async () => { + const { mockResolvedResult, server } = mockSolanaRpc; + + server?.get(`/v3/spot-prices`, (_: any, res: any) => { + return res.json(mockSpotPrices); + }); + + server?.post(`/solana/message/scan`, (_: any, res: any) => { + return res.json(MOCK_SECURITY_ALERTS_API_SCAN_TRANSACTIONS_RESPONSE); + }); + + const initialState = { + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + scopes: [Network.Localnet], + }, + }, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const { onKeyringRequest, mockJsonRpc } = await installSnap({ + options: { + ...mockPreferences, + unencryptedState: initialState, + }, + }); + + mockJsonRpc({ + method: 'snap_scheduleBackgroundEvent', + result: {}, + }); + + mockResolvedResult({ + method: 'getLatestBlockhash', + result: MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getMultipleAccounts', + result: MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_SWAP_RESPONSE.result, + }); + + const request: SolanaKeyringRequest = { + id: globalThis.crypto.randomUUID(), + scope: Network.Localnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + request: { + method: SolMethod.SignAndSendTransaction, + params: { + transaction: mockConfirmationContext.transaction, + scope: Network.Localnet, + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + }, + }, + origin: TEST_ORIGIN, + }; + + const response = onKeyringRequest({ + origin: TEST_ORIGIN, + method: KeyringRpcMethod.SubmitRequest, + params: request as unknown as KeyringRequest, + }); + + const screen1BeforeUpdate = await (response as any).getInterface(); + + /** + * Second render: + * - Get token prices + * - Get transaction fee + */ + await screen1BeforeUpdate.waitForUpdate(); + + /** + * Third render: + * - Scan transaction + */ + await screen1BeforeUpdate.waitForUpdate(); + + const screen1 = await (response as any).getInterface(); + + expect(screen1).toRender( + , + ); + }); + }); + + describe('when all preferences are disabled', () => { + it('renders the confirmation dialog', async () => { + const { mockResolvedResult } = mockSolanaRpc; + + const initialState = { + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + scopes: [Network.Localnet], + }, + [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: MOCK_SOLANA_KEYRING_ACCOUNT_1, + }, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: false, + simulateOnChainActions: false, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const { onKeyringRequest, mockJsonRpc } = await installSnap({ + options: { + ...mockPreferences, + unencryptedState: initialState, + }, + }); + + mockJsonRpc({ + method: 'snap_scheduleBackgroundEvent', + result: {}, + }); + + mockResolvedResult({ + method: 'getFeeForMessage', + result: MOCK_SOLANA_RPC_GET_FEE_FOR_MESSAGE_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getLatestBlockhash', + result: MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getMultipleAccounts', + result: MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_SWAP_RESPONSE.result, + }); + + const request: SolanaKeyringRequest = { + id: globalThis.crypto.randomUUID(), + scope: Network.Localnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + request: { + method: SolMethod.SignAndSendTransaction, + params: { + transaction: mockConfirmationContext.transaction, + scope: Network.Localnet, + account: { + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }, + }, + }, + origin: TEST_ORIGIN, + }; + + const response = onKeyringRequest({ + origin: TEST_ORIGIN, + method: KeyringRpcMethod.SubmitRequest, + params: request as unknown as KeyringRequest, + }); + + const screen1BeforeUpdate = await (response as any).getInterface(); + + /** + * Second render: + * - Get token prices, which does not happen + * - Get transaction fee + */ + await screen1BeforeUpdate.waitForUpdate(); + + /** + * Third render: + * - Scan transaction, which does not happen + */ + await screen1BeforeUpdate.waitForUpdate(); + + const screen1 = await (response as any).getInterface(); + + const mockContext = { + ...mockConfirmationContext, + preferences: mockPreferences, + scan: null, + tokenPrices: {}, + }; + + expect(screen1).toRender( + , + ); + }); + }); +}); + +describe('render tracking', () => { + const setupTest = async () => { + const trackError = jest.fn().mockResolvedValue('tracked-error-id'); + const calculateFee = jest.fn().mockReturnValue({ + totalFee: 5000n, + }); + const createInterface = jest.fn().mockResolvedValue('interface-id'); + const getPreferences = jest.fn().mockResolvedValue({ + locale: 'en', + currency: 'usd', + useExternalPricingData: true, + useSecurityAlerts: true, + simulateOnChainActions: true, + }); + const showDialog = jest.fn().mockResolvedValue('dialog-result'); + const updateInterface = jest.fn().mockResolvedValue(undefined); + const extractInstructionsFromUnknownBase64String = jest + .fn() + .mockResolvedValue([{}]); + const extractDestinationAddress = jest.fn(); + const nameResolutionService = { + resolveAddress: jest.fn().mockResolvedValue(null), + }; + const priceApiClient = { + getMultipleSpotPrices: jest.fn(), + }; + const transactionScanService = { + scanTransaction: jest.fn(), + }; + const state = { + setKey: jest.fn().mockResolvedValue(undefined), + }; + + jest.doMock('../../../../core/utils/errors', () => ({ + trackError, + })); + jest.doMock('../../../../core/fees', () => ({ + FeeCalculator: { + calculateFee, + }, + })); + jest.doMock('../../../../core/utils/interface', () => ({ + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME: + 'confirmation-interface', + createInterface, + getPreferences, + showDialog, + updateInterface, + })); + jest.doMock('./ConfirmTransactionRequest', () => ({ + ConfirmTransactionRequest: () => null, + })); + jest.doMock('../../../../entities', () => ({ + extractInstructionsFromUnknownBase64String, + })); + jest.doMock('../../utils/extractDestinationAddress', () => ({ + extractDestinationAddress, + })); + jest.doMock('../../../../snapContext', () => ({ + connection: { + getRpc: jest.fn().mockReturnValue({}), + }, + nameResolutionService, + priceApiClient, + state, + transactionScanService, + })); + + (globalThis as any).snap = { + request: jest.fn().mockResolvedValue(undefined), + }; + + let render: + | (( + incomingContext: ConfirmTransactionRequestContext, + ) => Promise) + | undefined; + jest.isolateModules(() => { + ({ render } = jest.requireActual('./render')); + }); + + if (!render) { + throw new Error('Failed to load render'); + } + + return { + render, + trackError, + calculateFee, + extractDestinationAddress, + nameResolutionService, + priceApiClient, + transactionScanService, + }; + }; + + it('tracks confirmation fallbacks', async () => { + const { + render, + trackError, + calculateFee, + extractDestinationAddress, + nameResolutionService, + priceApiClient, + transactionScanService, + } = await setupTest(); + + const destinationExtractError = new Error('Destination failed'); + const destinationDomainError = new Error('Domain failed'); + const pricesError = new Error('Prices failed'); + const feeError = new Error('Fee failed'); + const scanError = new Error('Scan failed'); + + extractDestinationAddress.mockImplementation(() => { + throw destinationExtractError; + }); + nameResolutionService.resolveAddress + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(destinationDomainError); + priceApiClient.getMultipleSpotPrices.mockRejectedValue(pricesError); + calculateFee.mockImplementation(() => { + throw feeError; + }); + transactionScanService.scanTransaction.mockRejectedValue(scanError); + + await expect( + render({ + method: SolMethod.SignAndSendTransaction, + scope: Network.Mainnet, + transaction: 'transaction', + account: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }, + origin: 'https://metamask.io', + } as unknown as ConfirmTransactionRequestContext), + ).resolves.toBe('dialog-result'); + + expect(trackError).toHaveBeenCalledWith(destinationExtractError); + expect(trackError).toHaveBeenCalledWith(pricesError); + expect(trackError).toHaveBeenCalledWith(feeError); + expect(trackError).toHaveBeenCalledWith(scanError); + expect(trackError).toHaveBeenCalledTimes(4); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx new file mode 100644 index 00000000..bb14ff0f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -0,0 +1,260 @@ +import { SolMethod } from '@metamask/keyring-api'; + +import { Network, Networks } from '../../../../core/constants/solana'; +import { FeeCalculator } from '../../../../core/fees'; +import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; +import { lamportsToSol } from '../../../../core/utils/conversion'; +import { trackError } from '../../../../core/utils/errors'; +import { + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME, + createInterface, + getPreferences, + showDialog, + updateInterface, +} from '../../../../core/utils/interface'; +import logger from '../../../../core/utils/logger'; +import { extractInstructionsFromUnknownBase64String } from '../../../../entities'; +import { + connection, + nameResolutionService, + priceApiClient, + state, + transactionScanService, +} from '../../../../snapContext'; +import { extractDestinationAddress } from '../../utils/extractDestinationAddress'; +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from './types'; + +export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { + method: SolMethod.SignAndSendTransaction, + scope: Network.Mainnet, + networkImage: SOL_IMAGE_SVG, + account: null, + accountDomain: null, + destinationAddress: null, + destinationDomain: null, + transaction: '', + scan: null, + scanFetchStatus: 'fetching', + feeEstimatedInSol: '0', + tokenPrices: {}, + tokenPricesFetchStatus: 'fetching', + origin: '', + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }, + advanced: { + shown: false, + instructions: [], + }, +}; + +/** + * Renders the confirmation dialog for a transaction request. + * + * @param incomingContext - The confirmation context. + * @returns The confirmation dialog. + */ +export async function render( + incomingContext: ConfirmTransactionRequestContext, +) { + /** + * First render: + * - Get preferences + */ + const context = { + ...DEFAULT_CONFIRMATION_CONTEXT, + ...incomingContext, + }; + + const preferencesPromise = getPreferences() + .then((preferences) => { + context.preferences = preferences; + }) + .catch(() => { + context.preferences = DEFAULT_CONFIRMATION_CONTEXT.preferences; + }); + + const instructionsPromise = extractInstructionsFromUnknownBase64String( + connection.getRpc(context.scope), + context.transaction, + ) + .then(async (instructions) => { + context.advanced.instructions = instructions; + + try { + context.destinationAddress = extractDestinationAddress(instructions); + } catch (error) { + await trackError(error); + context.destinationAddress = null; + } + }) + .catch(async (error) => { + await trackError(error); + logger.error(error); + context.advanced.instructions = []; + }); + + const accountDomainPromise = context.account?.address + ? nameResolutionService + .resolveAddress(context.scope, context.account.address) + .then((domain) => { + context.accountDomain = domain; + }) + : Promise.resolve().then(() => { + context.accountDomain = null; + }); + + await Promise.all([ + preferencesPromise, + instructionsPromise, + accountDomainPromise, + ]); + + const { + currency, + useExternalPricingData, + useSecurityAlerts, + simulateOnChainActions, + } = context.preferences; + + const id = await createInterface( + , + context, + ); + + const dialogPromise = showDialog(id); + + /** + * Second render: + * - Resolve destination domain + * - Get token prices + * - Get transaction fee + */ + const updatedContext1 = { + ...context, + }; + + const destinationDomainPromise = context.destinationAddress + ? nameResolutionService + .resolveAddress(context.scope, context.destinationAddress) + .then((domain) => { + updatedContext1.destinationDomain = domain; + }) + .catch(async (error) => { + await trackError(error); + updatedContext1.destinationDomain = null; + }) + : Promise.resolve(); + + const assets = [Networks[context.scope].nativeToken.caip19Id]; + + const tokenPricesPromise = useExternalPricingData + ? priceApiClient + .getMultipleSpotPrices(assets, currency) + .then((prices) => { + updatedContext1.tokenPrices = prices; + updatedContext1.tokenPricesFetchStatus = 'fetched'; + }) + .catch(async (error) => { + await trackError(error); + updatedContext1.tokenPricesFetchStatus = 'error'; + }) + : Promise.resolve().then(() => { + updatedContext1.tokenPricesFetchStatus = 'fetched'; + updatedContext1.tokenPrices = {}; + }); + + await Promise.all([destinationDomainPromise, tokenPricesPromise]); + + let feeEstimatedInSol: string | null = null; + try { + const { totalFee } = FeeCalculator.calculateFee(context.transaction); + feeEstimatedInSol = lamportsToSol(totalFee).toString(); + } catch (error) { + await trackError(error); + feeEstimatedInSol = null; + } + updatedContext1.feeEstimatedInSol = feeEstimatedInSol; + + await updateInterface( + id, + , + updatedContext1, + ); + + /** + * Third render: + * - Scan transaction + */ + const updatedContext2 = { + ...updatedContext1, + }; + + const options = []; + + if (simulateOnChainActions) { + options.push('simulation'); + } + + if (useSecurityAlerts) { + options.push('validation'); + } + + if (simulateOnChainActions || useSecurityAlerts) { + const transactionScanPromise = transactionScanService + .scanTransaction({ + method: updatedContext2.method, + accountAddress: updatedContext2.account?.address ?? '', + transaction: updatedContext2.transaction, + scope: updatedContext2.scope, + origin: updatedContext2.origin, + options, + }) + .then(async (scan) => { + updatedContext2.scan = scan; + updatedContext2.scanFetchStatus = scan ? 'fetched' : 'error'; + }) + .catch(async (error) => { + await trackError(error); + updatedContext2.scan = null; + updatedContext2.scanFetchStatus = 'error'; + }); + + await Promise.all([transactionScanPromise]); + } else { + updatedContext2.scanFetchStatus = 'fetched'; + updatedContext2.scan = null; + } + + await updateInterface( + id, + , + updatedContext2, + ); + + await state.setKey( + `mapInterfaceNameToId.${CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME}`, + id, + ); + + // Schedule the next refresh + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT20S', + request: { method: 'refreshConfirmationEstimation' }, + }, + }); + + return dialogPromise; +} diff --git a/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/types.ts b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/types.ts new file mode 100644 index 00000000..1e2d34f5 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/confirmation/views/ConfirmTransactionRequest/types.ts @@ -0,0 +1,32 @@ +import type { SolMethod } from '@metamask/keyring-api'; + +import type { SpotPrices } from '../../../../core/clients/price-api/types'; +import type { Network } from '../../../../core/constants/solana'; +import type { TransactionScanResult } from '../../../../core/services/transaction-scan/types'; +import type { FetchStatus, Preferences } from '../../../../core/types/snap'; +import type { + InstructionParseResult, + SolanaKeyringAccount, +} from '../../../../entities'; + +export type ConfirmTransactionRequestContext = { + method: SolMethod; + scope: Network; + networkImage: string | null; + account: SolanaKeyringAccount | null; + accountDomain: string | null; + destinationAddress: string | null; + destinationDomain: string | null; + preferences: Preferences; + transaction: string; + feeEstimatedInSol: string | null; + tokenPrices: SpotPrices; + tokenPricesFetchStatus: FetchStatus; + scan: TransactionScanResult | null; + scanFetchStatus: FetchStatus; + origin: string; + advanced: { + shown: boolean; + instructions: InstructionParseResult[]; + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/Send.tsx b/merged-packages/solana-wallet-snap/src/features/send/Send.tsx new file mode 100644 index 00000000..46022e2b --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/Send.tsx @@ -0,0 +1,55 @@ +import type { SendContext, SendFlowStage } from './types'; +import { SendForm } from './views/SendForm/SendForm'; +import { SendPending } from './views/SendPending/SendPending'; +import { TransactionConfirmation } from './views/TransactionConfirmation/TransactionConfirmation'; +import { TransactionFailure } from './views/TransactionFailure/TransactionFailure'; +import { TransactionSuccess } from './views/TransactionSuccess/TransactionSuccess'; + +export type SendProps = { + context: SendContext; + inputToAddress?: string; + inputAmount?: string; +}; + +/** + * Maps each stage of the send flow to its corresponding component. + */ +export const MapStageToComponent: Record< + SendFlowStage, + ({ context, inputToAddress, inputAmount }: SendProps) => JSX.Element +> = { + 'send-form': SendForm, + 'transaction-confirmation': TransactionConfirmation, + 'send-pending': SendPending, + 'transaction-success': TransactionSuccess, + 'transaction-failure': TransactionFailure, +}; + +/** + * Main component that controls the complete send flow. + * + * It renders the correct component based on the current stage. + * The stage is set by the various event handlers of each component. + * + * @param props - The props for the send flow controller. + * @param props.context - The context for the send flow. + * @param props.inputToAddress - The input to address for the send flow. + * @param props.inputAmount - The input amount for the send flow. + * @returns The component for the send flow stage. + */ +export const Send = ({ + context, + inputToAddress = undefined, + inputAmount = undefined, +}: SendProps) => { + const stage = context.stage ?? 'send-form'; + const Component = MapStageToComponent[stage]; + + return ( + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/components/AmountInput/AmountInput.tsx b/merged-packages/solana-wallet-snap/src/features/send/components/AmountInput/AmountInput.tsx new file mode 100644 index 00000000..d36f0a19 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/components/AmountInput/AmountInput.tsx @@ -0,0 +1,60 @@ +import { Box, Button, Field, Icon, Input, Text } from '@metamask/snaps-sdk/jsx'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { i18n } from '../../../../core/utils/i18n'; +import type { Locale } from '../../../../core/utils/i18n'; +import { SendCurrencyType, SendFormNames } from '../../types'; + +type AmountInputProps = { + name: string; + value: string | null; + tokenSymbol: string; + currency: string; + currencyType: SendCurrencyType; + locale: Locale; + swapCurrencyButtonDisabled?: boolean; +}; + +export const AmountInput: SnapComponent = ({ + name, + value, + tokenSymbol, + currency, + currencyType, + locale, + swapCurrencyButtonDisabled = false, +}) => { + const translate = i18n(locale); + const symbol = + currencyType === SendCurrencyType.FIAT + ? currency.toUpperCase() + : tokenSymbol; + + return ( + + + + + {symbol} + + + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/components/ToAddressField/ToAddressField.tsx b/merged-packages/solana-wallet-snap/src/features/send/components/ToAddressField/ToAddressField.tsx new file mode 100644 index 00000000..8dad3eb9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/components/ToAddressField/ToAddressField.tsx @@ -0,0 +1,78 @@ +import type { SnapComponent, TextColors } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Button, + Field, + Icon, + IconName, + Input, + Text, +} from '@metamask/snaps-sdk/jsx'; + +import type { FetchStatus } from '../../../../core/types/snap'; +import { i18n } from '../../../../core/utils/i18n'; +import type { Locale } from '../../../../core/utils/i18n'; +import { SendFormNames } from '../../types'; + +type ToAddressFieldProps = { + name: string; + value: string | null; + error: string; + locale: Locale; + showClearButton: boolean; + disabled: boolean; + domainResolutionStatus: FetchStatus | null; +}; + +const domainResolutionStatusToTextColor: Record< + FetchStatus, + TextColors | null +> = { + initial: null, + fetching: 'default', + fetched: 'success', + error: null, +}; + +export const ToAddressField: SnapComponent = ({ + name, + value, + error, + locale, + showClearButton, + disabled, + domainResolutionStatus, +}) => { + const translate = i18n(locale); + + return ( + + + + {showClearButton ? ( + + + + ) : null} + + {domainResolutionStatus && + domainResolutionStatusToTextColor[domainResolutionStatus] !== null && ( + + {translate( + `send.toDomainResolutionStatus.${domainResolutionStatus}`, + )} + + )} + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/components/TransactionDetails/TransactionDetails.tsx b/merged-packages/solana-wallet-snap/src/features/send/components/TransactionDetails/TransactionDetails.tsx new file mode 100644 index 00000000..1059db29 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/components/TransactionDetails/TransactionDetails.tsx @@ -0,0 +1,104 @@ +import { + Address, + Box, + Link, + Row, + Section, + Text, + Value, +} from '@metamask/snaps-sdk/jsx'; + +import { Domain } from '../../../../core/components/Domain/Domain'; +import { Networks } from '../../../../core/constants/solana'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import { formatCrypto } from '../../../../core/utils/formatCrypto'; +import { formatFiat } from '../../../../core/utils/formatFiat'; +import { getSolanaExplorerUrl } from '../../../../core/utils/getSolanaExplorerUrl'; +import { i18n } from '../../../../core/utils/i18n'; +import { tokenToFiat } from '../../../../core/utils/tokenToFiat'; +import type { SendContext } from '../../types'; + +export type TransactionDetailsProps = { + context: SendContext; +}; + +export const TransactionDetails = ({ context }: TransactionDetailsProps) => { + const { + scope, + fromAccountId, + toAddress, + accounts, + feeEstimatedInSol, + preferences: { locale, currency }, + transaction, + feePaidInSol, + tokenPrices, + fromDomain, + toDomain, + } = context; + const translate = i18n(locale); + + const network = Networks[scope]; + const fromAddress = accounts.find((account) => account.id === fromAccountId) + ?.address as string; + + const fromAddressCaip2 = addressToCaip10(scope, fromAddress); + + const networkName = network.name; + const networkSymbol = network.nativeToken.symbol; + const tokenPrice = tokenPrices[network.nativeToken.caip19Id]?.price; + + const transactionSpeed = '<1s'; + + const fee = transaction ? feePaidInSol : feeEstimatedInSol; + const feeToDisplay = fee ? formatCrypto(fee, networkSymbol, locale) : ''; + const feeInUserCurrency = + tokenPrice === undefined || fee === null + ? '' + : formatFiat(tokenToFiat(fee, tokenPrice), currency, locale); + + return ( + +
+ + {fromDomain ? ( + + ) : ( + +
+ + )} + + + {toAddress ? ( + + {toDomain ? ( + + ) : ( + +
+ + )} + + ) : null} +
+ +
+ + {networkName} + + + + {transactionSpeed} + + + + + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/render.test.tsx b/merged-packages/solana-wallet-snap/src/features/send/render.test.tsx new file mode 100644 index 00000000..89bba751 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/render.test.tsx @@ -0,0 +1,638 @@ +import type { CaipAssetType } from '@metamask/keyring-api'; +import { getMockAccount, installSnap } from '@metamask/snaps-jest'; + +import type { SpotPrices } from '../../core/clients/price-api/types'; +import { + KnownCaip19Id, + Network, + SOL_SYMBOL, +} from '../../core/constants/solana'; +import { RpcRequestMethod } from '../../core/handlers/onRpcRequest/types'; +import { + MOCK_SOLANA_RPC_GET_BALANCE_RESPONSE, + MOCK_SOLANA_RPC_GET_FEE_FOR_MESSAGE_RESPONSE, + MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE, + MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_RESPONSE, + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE, + MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE, + MOCK_SOLANA_RPC_SIMULATE_TRANSACTION_RESPONSE, +} from '../../core/services/mocks/mockSolanaRpcResponses'; +import { + MOCK_SEED_PHRASE, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../core/test/mocks/solana-keyring-accounts'; +import type { MockSolanaRpc } from '../../core/test/mocks/startMockSolanaRpc'; +import { startMockSolanaRpc } from '../../core/test/mocks/startMockSolanaRpc'; +import { EXPECTED_NATIVE_SOL_TRANSFER_DATA } from '../../core/test/mocks/transactions-data/native-sol-transfer'; +import { TEST_ORIGIN } from '../../core/test/utils'; +import type { Preferences } from '../../core/types/snap'; +import { buildUrl } from '../../core/utils/buildUrl'; +import { trackError } from '../../core/utils/errors'; +import { + accountsService, + assetsService, + configProvider, + connection, + priceApiClient, + state, +} from '../../snapContext'; +import { DEFAULT_SEND_CONTEXT, renderSend } from './render'; +import { Send } from './Send'; +import { SendCurrencyType, SendFormNames } from './types'; +import type { SendContext } from './types'; +import { TransactionConfirmationNames } from './views/TransactionConfirmation/TransactionConfirmation'; + +jest.mock('../../core/utils/errors', () => ({ + ...jest.requireActual('../../core/utils/errors'), + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +const solanaKeyringAccounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +]; + +const solanaAccountBalances: Record = + { + [KnownCaip19Id.SolLocalnet]: { + amount: '0.123456789', + unit: SOL_SYMBOL, + }, + 'solana:123456789abcdef/token:address1': { + amount: '0.123456789', + unit: '', + }, + 'solana:123456789abcdef/token:address2': { + amount: '0.123456789', + unit: '', + }, + }; + +const mockSpotPrices: SpotPrices = { + [KnownCaip19Id.SolLocalnet]: { + id: 'solana', + price: 200, + marketCap: 60217502031.67665, + allTimeHigh: 271.90599356377726, + allTimeLow: 0.46425554356391946, + totalVolume: 3389485617.517553, + high1d: 120.28162239575909, + low1d: 114.6267638476733, + circulatingSupply: 512506275.4700137, + dilutedMarketCap: 70208307228.42435, + marketCapPercentChange1d: 1.82897, + priceChange1d: 2.03, + pricePercentChange1h: -0.7015657267954617, + pricePercentChange1d: 1.6270441732346845, + pricePercentChange7d: -10.985589910714582, + pricePercentChange14d: 2.557473792001135, + pricePercentChange30d: -11.519171371325216, + pricePercentChange200d: -4.453777067234332, + pricePercentChange1y: -35.331458644625535, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:123456789abcdef/token:address1': { + id: 'euro-coin', + price: 200, + marketCap: 142095635.08509836, + allTimeHigh: 1.2514850885107882, + allTimeLow: 0.04899146959823566, + totalVolume: 25199808.258576106, + high1d: 1.0048961747745884, + low1d: 0.9993340188256516, + circulatingSupply: 142084788.4864096, + dilutedMarketCap: 142095635.08509836, + marketCapPercentChange1d: 2.2575, + priceChange1d: -0.002559725137667668, + pricePercentChange1h: 0.03324277835545184, + pricePercentChange1d: -0.23674527641785267, + pricePercentChange7d: -0.2372037121786562, + pricePercentChange14d: -1.230607529415818, + pricePercentChange30d: 4.034620460890957, + pricePercentChange200d: -2.4622235894139086, + pricePercentChange1y: 0.2685816973195049, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:123456789abcdef/token:address2': { + id: 'usd-coin', + price: 200, + marketCap: 55688170578.59265, + allTimeHigh: 1.084620410042683, + allTimeLow: 0.8136015803527613, + totalVolume: 8880279668.334307, + high1d: 0.9270204293335238, + low1d: 0.9267951620175918, + circulatingSupply: 60073257677.05562, + dilutedMarketCap: 55717659417.21691, + marketCapPercentChange1d: 0.02765, + priceChange1d: 0.00012002, + pricePercentChange1h: 0.005951260480466856, + pricePercentChange1d: 0.012003855299833856, + pricePercentChange7d: 0.010535714950044883, + pricePercentChange14d: 0.013896106834960937, + pricePercentChange30d: 0.009428708838368462, + pricePercentChange200d: -0.03983945503023834, + pricePercentChange1y: 0.0011388468382004923, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, +}; + +const mockContext: SendContext = { + ...DEFAULT_SEND_CONTEXT, + accounts: solanaKeyringAccounts, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + scope: Network.Localnet, + currencyType: SendCurrencyType.TOKEN, + tokenCaipId: KnownCaip19Id.SolLocalnet, + assets: [ + KnownCaip19Id.SolLocalnet, + 'solana:123456789abcdef/token:address1', + 'solana:123456789abcdef/token:address2', + ], + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: solanaAccountBalances, + [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: solanaAccountBalances, + }, + tokenPrices: mockSpotPrices, + loading: false, +}; + +const tokenImageUrl = buildUrl({ + baseUrl: configProvider.get().staticApi.baseUrl, + path: '/api/v2/tokenIcons/assets/{assetId}.png', + pathParams: { + assetId: KnownCaip19Id.SolLocalnet.replace(/:/gu, '/'), + }, + encodePathParams: false, +}); + +describe('Send', () => { + let mockSolanaRpc: MockSolanaRpc; + + beforeAll(() => { + mockSolanaRpc = startMockSolanaRpc(); + }); + + afterAll(() => { + mockSolanaRpc.shutdown(); + }); + + it('renders the send form', async () => { + const { mockResolvedResult, server } = mockSolanaRpc; + + // temporary mock for the token prices + // FIXME: when we have a better way to handle external requests + server?.get(`/v3/spot-prices`, (_: any, res: any) => { + return res.json(mockSpotPrices); + }); + + const mockAssetEntities = [ + { + assetType: KnownCaip19Id.SolLocalnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Localnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '123456789', + uiAmount: '0.123456789', + }, + { + assetType: 'solana:123456789abcdef/token:address1', + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Localnet, + symbol: 'EURO-COIN', + mint: 'address1', + pubkey: 'pubkey1', + decimals: 6, + rawAmount: '123456789', + uiAmount: '123.456789', + }, + { + assetType: 'solana:123456789abcdef/token:address2', + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Localnet, + symbol: 'USDC', + mint: 'address2', + pubkey: 'pubkey2', + decimals: 6, + rawAmount: '123456789', + uiAmount: '123.456789', + }, + ]; + + const initialState = { + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + entropySource: 'default', + }, + [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: { + ...MOCK_SOLANA_KEYRING_ACCOUNT_1, + entropySource: 'alternative', + }, + }, + assetEntities: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: mockAssetEntities, + [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: mockAssetEntities, + }, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const { request, mockJsonRpc } = await installSnap({ + options: { + ...mockPreferences, + secretRecoveryPhrase: MOCK_SEED_PHRASE, + unencryptedState: initialState, + accounts: [ + getMockAccount({ + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + selected: true, + assets: Object.keys(solanaAccountBalances) as CaipAssetType[], + scopes: [Network.Localnet], + }), + getMockAccount({ + address: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + selected: false, + assets: Object.keys(solanaAccountBalances) as CaipAssetType[], + scopes: [Network.Localnet], + }), + ], + assets: { + [KnownCaip19Id.SolLocalnet]: { + symbol: 'SOL', + name: 'Solana', + }, + 'solana:123456789abcdef/token:address1': { + symbol: 'EURO-COIN', + name: 'Euro Coin', + }, + 'solana:123456789abcdef/token:address2': { + symbol: 'USDC', + name: 'USDC', + }, + }, + }, + }); + + mockJsonRpc({ + method: 'snap_scheduleBackgroundEvent', + result: {}, + }); + + mockJsonRpc({ + method: 'snap_getWebSockets', + result: [], + }); + + mockJsonRpc({ + method: 'snap_openWebSocket', + result: 'mock-id', + }); + + mockResolvedResult({ + method: 'getLatestBlockhash', + result: MOCK_SOLANA_RPC_GET_LATEST_BLOCKHASH_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'sendTransaction', + result: MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getTokenAccountsByOwner', + result: MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getFeeForMessage', + result: MOCK_SOLANA_RPC_GET_FEE_FOR_MESSAGE_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getBalance', + result: MOCK_SOLANA_RPC_GET_BALANCE_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'simulateTransaction', + result: MOCK_SOLANA_RPC_SIMULATE_TRANSACTION_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getMultipleAccounts', + result: MOCK_SOLANA_RPC_GET_MULTIPLE_ACCOUNTS_RESPONSE.result, + }); + + mockResolvedResult({ + method: 'getTransaction', + result: { + transaction: EXPECTED_NATIVE_SOL_TRANSFER_DATA.transaction, + } as any, + }); + + mockResolvedResult({ + method: 'getMinimumBalanceForRentExemption', + result: 890880, // 890880 lamports = 0.00089088 SOL + }); + + const response = request({ + origin: TEST_ORIGIN, + method: RpcRequestMethod.StartSendTransactionFlow, + params: { + scope: Network.Localnet, // Routes the call to the mock RPC server running locally + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + }, + }); + + const screen1BeforeUpdate = await response.getInterface(); + await screen1BeforeUpdate.waitForUpdate(); + + const screen1 = await response.getInterface(); + + const updatedContext1: SendContext = mockContext; + + expect(screen1).toRender(); + + await screen1.typeInField( + SendFormNames.DestinationAccountInput, + MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + ); + + // two rerenders are happening here + await response.getInterface(); + const screen2 = await response.getInterface(); + + const updatedContext2: SendContext = { + ...updatedContext1, + destinationAddressOrDomain: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + }; + + expect(screen2).toRender(); + + await screen2.typeInField(SendFormNames.AmountInput, '0.001'); + + await screen2.waitForUpdate(); + + const screen3 = await response.getInterface(); + + const updatedContext3: SendContext = { + ...updatedContext2, + amount: '0.001', + transactionMessage: 'some-base64-encoded-message', + }; + + expect(screen3).toRender(); + + await screen3.clickElement(SendFormNames.SendButton); + + const screen4 = await response.getInterface(); + + const updatedContext4: SendContext = { + ...updatedContext3, + stage: 'transaction-confirmation', + feeEstimatedInSol: '0.000005', + selectedTokenMetadata: { + symbol: 'SOL', + name: 'Solana', + asset: KnownCaip19Id.SolLocalnet, + imageUrl: tokenImageUrl, + }, + }; + + expect(screen4).toRender(); + + await screen4.clickElement(TransactionConfirmationNames.ConfirmButton); + + const screen5 = await response.getInterface(); + + const updatedContext5: SendContext = { + ...updatedContext4, + stage: 'transaction-success', + feePaidInSol: '0.000005', + transaction: { + result: 'success', + signature: MOCK_SOLANA_RPC_SEND_TRANSACTION_RESPONSE.result.signature, + }, + }; + + expect(screen5).toRender(); + }); + + it('fails when wrong scope', async () => { + const { request } = await installSnap(); + + const response = await request({ + origin: TEST_ORIGIN, + method: RpcRequestMethod.StartSendTransactionFlow, + params: { + scope: 'wrong scope', + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + }, + }); + + expect(response).toRespondWithError({ + code: expect.any(Number), + message: expect.stringMatching(/At path: scope/u), + stack: expect.any(String), + }); + }); + + it('fails when account is not a uuid', async () => { + const { request } = await installSnap(); + + const response = await request({ + origin: TEST_ORIGIN, + method: RpcRequestMethod.StartSendTransactionFlow, + params: { + scope: Network.Localnet, + account: 'not-a-uuid', + }, + }); + + expect(response).toRespondWithError({ + code: expect.any(Number), + message: expect.stringMatching(/At path: account/u), + stack: expect.any(String), + }); + }); +}); + +describe('Send tracking', () => { + const setupTest = () => { + const originalAssetsGetAll = assetsService.getAll; + const originalAssetsGetAssetsMetadata = assetsService.getAssetsMetadata; + const originalAccountsGetAll = accountsService.getAll; + const originalConnectionGetRpc = connection.getRpc; + const originalPriceGetMultipleSpotPrices = + priceApiClient.getMultipleSpotPrices; + const originalStateGetKey = state.getKey; + const originalStateSetKey = state.setKey; + + const mockedTrackError = jest.mocked(trackError); + mockedTrackError.mockClear(); + + const snapRequest = jest + .fn() + .mockImplementation(async ({ method }: any) => { + if (method === 'snap_getPreferences') { + return Promise.resolve({ + locale: 'en', + currency: 'usd', + useExternalPricingData: true, + }); + } + + if (method === 'snap_createInterface') { + return Promise.resolve('interface-id'); + } + + if (method === 'snap_updateInterface') { + return Promise.resolve(undefined); + } + + if (method === 'snap_dialog') { + return Promise.resolve('dialog-result'); + } + + if (method === 'snap_scheduleBackgroundEvent') { + return Promise.resolve(undefined); + } + + throw new Error(`Unexpected snap.request call: ${method}`); + }); + + jest + .spyOn(assetsService, 'getAll') + .mockImplementation() + .mockResolvedValue([ + { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1', + uiAmount: '1', + }, + ]); + jest.spyOn(assetsService, 'getAssetsMetadata').mockImplementation(); + jest + .spyOn(accountsService, 'getAll') + .mockImplementation() + .mockResolvedValue([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + const rentExemptionSend = jest.fn(); + jest + .spyOn(connection, 'getRpc') + .mockImplementation() + .mockReturnValue({ + getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ + send: rentExemptionSend, + }), + } as never); + jest.spyOn(priceApiClient, 'getMultipleSpotPrices').mockImplementation(); + jest + .spyOn(state, 'getKey') + .mockImplementation() + .mockResolvedValue(undefined); + jest + .spyOn(state, 'setKey') + .mockImplementation() + .mockResolvedValue(undefined); + + (globalThis as any).snap = { + request: snapRequest, + }; + + return { + trackError: mockedTrackError, + assetsService, + connection, + priceApiClient, + cleanup: () => { + assetsService.getAll = originalAssetsGetAll; + assetsService.getAssetsMetadata = originalAssetsGetAssetsMetadata; + accountsService.getAll = originalAccountsGetAll; + connection.getRpc = originalConnectionGetRpc; + priceApiClient.getMultipleSpotPrices = + originalPriceGetMultipleSpotPrices; + state.getKey = originalStateGetKey; + state.setKey = originalStateSetKey; + }, + }; + }; + + it('tracks initialization fetch failures', async () => { + const { + trackError: trackErrorMock, + assetsService: assetsServiceMock, + connection: connectionMock, + priceApiClient: priceApiClientMock, + cleanup, + } = setupTest(); + + try { + const metadataError = new Error('Metadata failed'); + const pricesError = new Error('Prices failed'); + const rentError = new Error('Rent failed'); + + jest + .mocked(assetsServiceMock.getAssetsMetadata) + .mockRejectedValue(metadataError); + jest + .mocked(priceApiClientMock.getMultipleSpotPrices) + .mockRejectedValue(pricesError); + jest.mocked(connectionMock.getRpc).mockReturnValue({ + getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ + send: jest.fn().mockRejectedValue(rentError), + }), + } as any); + + await expect( + renderSend({ + request: { + params: { + scope: Network.Mainnet, + account: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + }, + }, + } as any), + ).resolves.toBe('dialog-result'); + + expect(trackErrorMock).toHaveBeenCalledWith(metadataError); + expect(trackErrorMock).toHaveBeenCalledWith(pricesError); + expect(trackErrorMock).toHaveBeenCalledWith(rentError); + expect(trackErrorMock).toHaveBeenCalledTimes(3); + } finally { + cleanup(); + } + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/render.tsx b/merged-packages/solana-wallet-snap/src/features/send/render.tsx new file mode 100644 index 00000000..dc1524d9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/render.tsx @@ -0,0 +1,195 @@ +import type { CaipAssetType } from '@metamask/keyring-api'; +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { KnownCaip19Id, Network, Networks } from '../../core/constants/solana'; +import type { UnencryptedStateValue } from '../../core/services/state/State'; +import { lamportsToSol } from '../../core/utils/conversion'; +import { trackError } from '../../core/utils/errors'; +import { + createInterface, + getPreferences, + SEND_FORM_INTERFACE_NAME, + showDialog, + updateInterface, +} from '../../core/utils/interface'; +import { + accountsService, + assetsService, + connection, + priceApiClient, + state, +} from '../../snapContext'; +import { Send } from './Send'; +import type { SendContext } from './types'; +import { SendCurrencyType } from './types'; +import { getBalancesInScope } from './utils/getBalancesInScope'; +import { StartSendTransactionFlowParamsStruct } from './views/SendForm/validation'; + +export const DEFAULT_SEND_CONTEXT: SendContext = { + scope: Network.Mainnet, + fromAccountId: '', + fromDomain: null, + amount: '', + destinationAddressOrDomain: null, + toDomain: null, + toAddress: null, + domainResolutionStatus: null, + feeEstimatedInSol: '0', + feePaidInSol: '0', + tokenCaipId: KnownCaip19Id.SolMainnet, + accounts: [], + currencyType: SendCurrencyType.TOKEN, + validation: {}, + balances: {}, + assets: [], + tokenPrices: {}, + selectedTokenMetadata: null, + tokenPricesFetchStatus: 'initial', + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }, + error: null, + buildingTransaction: false, + transactionMessage: null, + transaction: null, + stage: 'send-form', + minimumBalanceForRentExemptionSol: '0.002', // Pessimistic default value. Will only be used if we cannot fetch the actual minimum balance for rent exemption. + loading: true, +}; + +/** + * Renders the send form interface. + * + * @param params - The parameters for rendering the send form interface. + * @param params.request - The request object. + * @returns A promise that resolves when the interface is created. + */ +export const renderSend: OnRpcRequestHandler = async ({ request }) => { + const { params } = request; + assert(params, StartSendTransactionFlowParamsStruct); + + const { scope, account } = params; + + const tokenCaipId = + (params.assetId as CaipAssetType) ?? Networks[scope].nativeToken.caip19Id; + + const context: SendContext = { + ...DEFAULT_SEND_CONTEXT, + scope, + fromAccountId: account, + tokenCaipId, + loading: true, + }; + + const [assetEntities, keyringAccounts, tokenPrices, preferences] = + await Promise.all([ + assetsService.getAll(), + accountsService.getAll(), + state.getKey('tokenPrices'), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + context.balances = getBalancesInScope(scope, assetEntities); + context.assets = assetEntities.map((asset) => asset.assetType); + context.accounts = keyringAccounts; + context.preferences = preferences; + context.tokenPrices = tokenPrices ?? {}; + + const id = await createInterface(, context); + + const dialogPromise = showDialog(id); + + const tokenMetadataPromise = assetsService + .getAssetsMetadata([context.tokenCaipId]) + .then((metadata) => { + const tokenMetadata = metadata[context.tokenCaipId]; + + if (!tokenMetadata?.symbol || !tokenMetadata?.name) { + return; + } + + context.selectedTokenMetadata = { + symbol: tokenMetadata.symbol, + name: tokenMetadata.name, + asset: context.tokenCaipId, + imageUrl: null, + }; + }) + .catch(async (error) => { + await trackError(error); + return null; + }); + + let tokenPricesPromise; + + if (context.preferences.useExternalPricingData) { + tokenPricesPromise = priceApiClient + .getMultipleSpotPrices(context.assets, context.preferences.currency) + .then((prices) => { + context.tokenPrices = { + ...context.tokenPrices, + ...prices, + }; + context.tokenPricesFetchStatus = 'fetched'; + }) + .catch(async (error) => { + await trackError(error); + context.tokenPricesFetchStatus = 'error'; + }); + } else { + context.tokenPricesFetchStatus = 'fetched'; + } + + const minimumBalanceForRentExemptionPromise = connection + .getRpc(scope) + .getMinimumBalanceForRentExemption(BigInt(0)) + .send() + .then((balance) => { + context.minimumBalanceForRentExemptionSol = + lamportsToSol(balance).toString(); + }) + .catch(async (error) => { + await trackError(error); + // Do nothing, the value set on default context will be used. + }); + + context.loading = true; + + await updateInterface(id, , context); + + // eslint-disable-next-line require-atomic-updates + context.loading = false; + + /** + * 6. Refresh token prices (from api) + * 7. Get selected token metadata (from api) + * 8. Get the minimum balance for rent exemption (from api) + */ + await Promise.all([ + tokenPricesPromise, + tokenMetadataPromise, + minimumBalanceForRentExemptionPromise, + ]); + + await updateInterface(id, , context); + + await state.setKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`, id); + + // Schedule the next refresh + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { duration: 'PT30S', request: { method: 'refreshSend' } }, + }); + + return dialogPromise; +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/selectors.test.ts b/merged-packages/solana-wallet-snap/src/features/send/selectors.test.ts new file mode 100644 index 00000000..27a87cdb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/selectors.test.ts @@ -0,0 +1,165 @@ +import { KnownCaip19Id, Network, Networks } from '../../core/constants/solana'; +import QUESTION_MARK_SVG from '../../core/img/question-mark.svg'; +import { DEFAULT_SEND_CONTEXT } from './render'; +import { + getNativeTokenPrice, + getSelectedTokenMetadata, + getSelectedTokenPrice, + getTokenAmount, +} from './selectors'; +import type { SendContext } from './types'; +import { SendCurrencyType } from './types'; + +describe('Send selectors', () => { + const mockTokenCaipId = KnownCaip19Id.UsdcLocalnet; + const mockContext: SendContext = { + ...DEFAULT_SEND_CONTEXT, + amount: '100', + tokenPrices: { + [mockTokenCaipId]: { + id: 'usd-coin', + price: 2, + marketCap: 55664178509.26305, + allTimeHigh: 1.084554895901498, + allTimeLow: 0.8135524365156085, + totalVolume: 9422406824.740463, + high1d: 0.9269644346770277, + low1d: 0.926739180967879, + circulatingSupply: 60049607340.55973, + dilutedMarketCap: 55715589401.733986, + marketCapPercentChange1d: -0.01464, + priceChange1d: -0.000001640813751647, + pricePercentChange1h: 0.003262897712363837, + pricePercentChange1d: -0.00016408418291423345, + pricePercentChange7d: 0.009232558804102214, + pricePercentChange14d: 0.012592906902478116, + pricePercentChange30d: 0.008125567116924248, + pricePercentChange200d: -0.012339503258443628, + pricePercentChange1y: -0.0001641868647780945, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + [Networks[Network.Mainnet].nativeToken.caip19Id]: { + id: 'solana', + price: 50, + marketCap: 60616788101.97389, + allTimeHigh: 271.88956967253705, + allTimeLow: 0.4642275012156975, + totalVolume: 3363833590.4901085, + high1d: 120.27435704548662, + low1d: 114.61984006685489, + circulatingSupply: 512506456.9100605, + dilutedMarketCap: 70673814622.89714, + marketCapPercentChange1d: 2.65784, + priceChange1d: 3.76, + pricePercentChange1h: -0.1991040003642417, + pricePercentChange1d: 3.0304256401948635, + pricePercentChange7d: -10.23628144764743, + pricePercentChange14d: 3.420785507329618, + pricePercentChange30d: -10.774354508031294, + pricePercentChange200d: -3.649484892677309, + pricePercentChange1y: -34.78708964545803, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + }, + tokenCaipId: mockTokenCaipId, + currencyType: SendCurrencyType.TOKEN, + selectedTokenMetadata: { + symbol: 'USDC', + imageUrl: 'https://example.com/test-image.png', + name: 'USD Coin', + asset: mockTokenCaipId, + }, + scope: Network.Mainnet, + }; + + describe('getTokenAmount', () => { + it('returns amount directly when currency type is TOKEN', () => { + const result = getTokenAmount(mockContext); + expect(result).toBe('100'); + }); + + it('converts USD amount to token amount when currency type is USD', () => { + const usdContext = { + ...mockContext, + currencyType: SendCurrencyType.FIAT, + amount: '200', + }; + const result = getTokenAmount(usdContext); + expect(result).toBe('100'); // 200 USD / 2 USD per token = 100 tokens + }); + + it('throws an error when token price is missing and currency type is fiat', () => { + const noPriceContext = { + ...mockContext, + currencyType: SendCurrencyType.FIAT, + tokenPrices: {}, + }; + expect(() => getTokenAmount(noPriceContext)).toThrow( + 'Token price is undefined, cannot convert to fiat amount.', + ); + }); + }); + + describe('getSelectedTokenMetadata', () => { + it('returns token metadata with required fields', () => { + const result = getSelectedTokenMetadata(mockContext); + expect(result).toStrictEqual({ + ...mockContext.selectedTokenMetadata, + tokenSymbol: 'USDC', + tokenImage: 'https://example.com/test-image.png', + }); + }); + + it('returns default values when token metadata is missing', () => { + const noMetadataContext = { + ...mockContext, + selectedTokenMetadata: null, + }; + const result = getSelectedTokenMetadata(noMetadataContext); + expect(result).toStrictEqual({ + tokenSymbol: 'UNKNOWN', + tokenImage: QUESTION_MARK_SVG, + }); + }); + }); + + describe('getSelectedTokenPrice', () => { + it('returns the price for the selected token', () => { + const result = getSelectedTokenPrice(mockContext); + expect(result).toBe(2); + }); + + it('returns undefined when price is not available', () => { + const noPriceContext = { + ...mockContext, + tokenPrices: {}, + }; + const result = getSelectedTokenPrice(noPriceContext); + expect(result).toBeUndefined(); + }); + }); + + describe('getNativeTokenPrice', () => { + it('returns the price for the native token', () => { + const result = getNativeTokenPrice(mockContext); + expect(result).toBe(50); + }); + + it('returns undefined when native token price is not available', () => { + const noPriceContext = { + ...mockContext, + tokenPrices: {}, + }; + const result = getNativeTokenPrice(noPriceContext); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/selectors.ts b/merged-packages/solana-wallet-snap/src/features/send/selectors.ts new file mode 100644 index 00000000..5a7bdfe8 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/selectors.ts @@ -0,0 +1,70 @@ +import BigNumber from 'bignumber.js'; + +import { Networks } from '../../core/constants/solana'; +import QUESTION_MARK_SVG from '../../core/img/question-mark.svg'; +import { SendCurrencyType } from './types'; +import type { SendContext } from './types'; + +/** + * Register here selectors that return data derived from the context. + */ + +export const getTokenAmount = (context: SendContext) => { + const { amount, tokenPrices, tokenCaipId, currencyType } = context; + const price = tokenPrices?.[tokenCaipId]?.price; + + if (currencyType === SendCurrencyType.TOKEN) { + return amount; + } + + if (price === undefined) { + throw new Error('Token price is undefined, cannot convert to fiat amount.'); + } + + if (amount === null) { + return null; + } + + return BigNumber(amount).dividedBy(BigNumber(price)).toString(); +}; + +export const getSelectedTokenMetadata = (context: SendContext) => { + const { selectedTokenMetadata } = context; + + return { + ...(selectedTokenMetadata ?? {}), + tokenSymbol: selectedTokenMetadata?.symbol ?? 'UNKNOWN', + tokenImage: selectedTokenMetadata?.imageUrl ?? QUESTION_MARK_SVG, + }; +}; + +export const getSelectedTokenPrice = ( + context: SendContext, +): number | undefined => { + const { tokenCaipId, tokenPrices } = context; + return tokenPrices?.[tokenCaipId]?.price; +}; + +export const getNativeTokenPrice = ( + context: SendContext, +): number | undefined => { + const { tokenPrices, scope } = context; + return tokenPrices?.[Networks[scope]?.nativeToken?.caip19Id]?.price; +}; + +export const getBalance = (context: SendContext) => { + const { balances, fromAccountId, tokenCaipId } = context; + return balances?.[fromAccountId]?.[tokenCaipId]?.amount ?? '0'; +}; + +export const getIsNativeToken = (context: SendContext) => { + const { tokenCaipId, scope } = context; + return tokenCaipId === Networks[scope]?.nativeToken?.caip19Id; +}; + +export const getNativeTokenBalance = (context: SendContext) => { + return getBalance({ + ...context, + tokenCaipId: Networks[context.scope].nativeToken.caip19Id, + }); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/types.ts b/merged-packages/solana-wallet-snap/src/features/send/types.ts new file mode 100644 index 00000000..eb9715e9 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/types.ts @@ -0,0 +1,81 @@ +import type { Balance, CaipAssetType } from '@metamask/keyring-api'; + +import type { SpotPrices } from '../../core/clients/price-api/types'; +import type { Network } from '../../core/constants/solana'; +import type { FormFieldError } from '../../core/types/error'; +import type { FetchStatus, Preferences } from '../../core/types/snap'; +import type { LocalizedMessage } from '../../core/utils/i18n'; +import type { SolanaKeyringAccount } from '../../entities'; + +export type SendFlowStage = + | 'send-form' + | 'transaction-confirmation' + | 'send-pending' + | 'transaction-success' + | 'transaction-failure'; + +export enum SendFormNames { + Form = 'send-form', + DestinationAccountInput = 'send-to', + BackButton = 'send-back-button', + SourceAccountSelector = 'send-account-selector', + AmountInput = 'send-amount-input', + AssetSelector = 'send-asset-selector', + SwapCurrencyButton = 'send-swap-currency', + MaxAmountButton = 'send-amount-input-max', + CancelButton = 'send-cancel-button', + SendButton = 'send-submit-button', + ClearButton = 'send-clear-button', + CloseButton = 'send-close-button', +} + +export enum SendCurrencyType { + TOKEN = 'TOKEN', + FIAT = 'USD', +} + +export type SendTransation = { + result: 'success' | 'failure'; + signature: string | null; +}; + +export type SendTokenMetadata = { + symbol: string; + name: string; + asset: CaipAssetType; + imageUrl: string | null; +}; + +export type SendContext = { + scope: Network; + fromAccountId: string; + fromDomain: string | null; + amount: string | null; + tokenCaipId: CaipAssetType; + destinationAddressOrDomain: string | null; + domainResolutionStatus: FetchStatus | null; + toAddress: string | null; + toDomain: string | null; + accounts: SolanaKeyringAccount[]; + feeEstimatedInSol: string | null; + feePaidInSol: string | null; + validation: Partial>; + currencyType: SendCurrencyType; + balances: Record>; + assets: CaipAssetType[]; + tokenPrices: SpotPrices; + tokenPricesFetchStatus: FetchStatus; + selectedTokenMetadata: SendTokenMetadata | null; + transaction: SendTransation | null; + buildingTransaction: boolean; + transactionMessage: string | null; + stage: SendFlowStage; + preferences: Preferences; + error: { + title: LocalizedMessage; + message: LocalizedMessage; + link?: string; + } | null; + minimumBalanceForRentExemptionSol: string; + loading: boolean; +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.test.tsx b/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.test.tsx new file mode 100644 index 00000000..20110265 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.test.tsx @@ -0,0 +1,189 @@ +/* eslint-disable camelcase */ + +import { KnownCaip19Id, Network } from '../../../core/constants/solana'; +import { MOCK_EXECUTION_SCENARIO_SEND_SOL } from '../../../core/services/signer/mocks/scenarios/sendSol'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../../core/test/mocks/solana-keyring-accounts'; +import { + getInterfaceContext, + updateInterface, +} from '../../../core/utils/interface'; +import { + keyring, + sendSolBuilder, + sendSplTokenBuilder, +} from '../../../snapContext'; +import { DEFAULT_SEND_CONTEXT } from '../render'; +import { SendCurrencyType } from '../types'; +import type { SendContext } from '../types'; +import { sendFieldsAreValid } from '../validation/form'; +import { buildTransactionMessageAndUpdateInterface_INTERNAL } from './buildTransactionMessageAndUpdateInterface'; + +// Mock dependencies +jest.mock('../../../core/utils/interface'); +jest.mock('../validation/form'); +jest.mock('../../../snapContext'); +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn: any) => fn, // Make debounce synchronous for testing +})); + +describe('buildTransactionMessageAndUpdateInterface', () => { + const mockId = 'test-id'; + const mockContext = { + ...DEFAULT_SEND_CONTEXT, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + tokenCaipId: KnownCaip19Id.SolTestnet, + scope: Network.Testnet, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '1.0', + preferences: { + locale: 'en', + currency: 'USD', + }, + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '100', + }, + }, + }, + currencyType: SendCurrencyType.TOKEN, + } as unknown as SendContext; + + beforeEach(() => { + jest.clearAllMocks(); + // Setup default mock implementations + (sendFieldsAreValid as jest.Mock).mockReturnValue(true); + (keyring.getAccountOrThrow as jest.Mock).mockResolvedValue({ + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + }); + + jest + .mocked(sendSolBuilder) + .buildTransactionMessage.mockResolvedValue( + MOCK_EXECUTION_SCENARIO_SEND_SOL.transactionMessage, + ); + jest.mocked(sendSolBuilder).getComputeUnitLimit.mockReturnValue(450); + jest + .mocked(sendSolBuilder) + .getComputeUnitPriceMicroLamportsPerComputeUnit.mockReturnValue(10000n); + + (getInterfaceContext as jest.Mock).mockResolvedValue(mockContext); + }); + + describe('buildTransactionMessageAndUpdateInterface_INTERNAL', () => { + it('does not build transaction if fields are invalid', async () => { + (sendFieldsAreValid as jest.Mock).mockReturnValue(false); + + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + expect(updateInterface).not.toHaveBeenCalled(); + expect(keyring.getAccountOrThrow).not.toHaveBeenCalled(); + }); + + it('builds SOL transfer transaction when tokenCaipId is native token', async () => { + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + expect(sendSolBuilder.buildTransactionMessage).toHaveBeenCalledWith({ + from: { address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address }, + to: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '1.0', + network: Network.Testnet, + }); + expect(updateInterface).toHaveBeenCalledTimes(2); + }); + + it('builds SPL token transaction when tokenCaipId is not native token', async () => { + const splContext = { + ...mockContext, + tokenCaipId: KnownCaip19Id.UsdcLocalnet, + } as unknown as SendContext; + + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + splContext, + ); + + expect(sendSplTokenBuilder.buildTransactionMessage).toHaveBeenCalled(); + expect(updateInterface).toHaveBeenCalledTimes(2); + }); + + it('handles transaction build errors', async () => { + (sendSolBuilder.buildTransactionMessage as jest.Mock).mockRejectedValue( + new Error('Build failed'), + ); + + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + expect(updateInterface).toHaveBeenCalledWith( + mockId, + expect.anything(), + expect.objectContaining({ + error: { + title: 'send.simulationTitleError', + message: 'send.simulationMessageError', + }, + buildingTransaction: false, + }), + ); + }); + + it('updates interface with fee estimation', async () => { + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + expect(updateInterface).toHaveBeenLastCalledWith( + mockId, + expect.anything(), + expect.objectContaining({ + feeEstimatedInSol: '0.000005004', // Base fee of 5000 + priority fee of 4 + transactionMessage: + MOCK_EXECUTION_SCENARIO_SEND_SOL.transactionMessageBase64Encoded, + buildingTransaction: false, + }), + ); + }); + + it('gracefully exits when interface context no longer exists after build', async () => { + // getInterfaceContext is called after build - returns null (interface closed) + (getInterfaceContext as jest.Mock).mockResolvedValueOnce(null); + + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + // Should only call updateInterface once (initial update), not the second time + expect(updateInterface).toHaveBeenCalledTimes(1); + }); + + it('gracefully exits when interface context no longer exists after error', async () => { + (sendSolBuilder.buildTransactionMessage as jest.Mock).mockRejectedValue( + new Error('Build failed'), + ); + (getInterfaceContext as jest.Mock).mockResolvedValue(null); + + await buildTransactionMessageAndUpdateInterface_INTERNAL( + mockId, + mockContext, + ); + + // Should only call updateInterface once (initial update), not for error display + expect(updateInterface).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.tsx b/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.tsx new file mode 100644 index 00000000..5895ab0d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/buildTransactionMessageAndUpdateInterface.tsx @@ -0,0 +1,155 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { address } from '@solana/kit'; +import debounce from 'lodash/fp/debounce'; +import pipe from 'lodash/fp/pipe'; + +import { Networks } from '../../../core/constants/solana'; +import { fromCompilableTransactionMessageToBase64String } from '../../../core/sdk-extensions/codecs'; +import { SendFeeCalculator } from '../../../core/services'; +import { withoutConcurrency } from '../../../core/utils/concurrency'; +import { lamportsToSol } from '../../../core/utils/conversion'; +import { + getInterfaceContext, + updateInterface, +} from '../../../core/utils/interface'; +import logger from '../../../core/utils/logger'; +import { + keyring, + sendSolBuilder, + sendSplTokenBuilder, +} from '../../../snapContext'; +import { getTokenAmount } from '../selectors'; +import { Send } from '../Send'; +import type { SendContext } from '../types'; +import { sendFieldsAreValid } from '../validation/form'; + +/** + * Builds a transaction message for the send form. + * + * @param context - The send context. + * @returns A promise that resolves to the transaction message. + */ +const buildTransactionMessage = async (context: SendContext) => { + const { fromAccountId, tokenCaipId, scope, toAddress } = context; + const tokenAmount = getTokenAmount(context); + const account = await keyring.getAccountOrThrow(fromAccountId); + + if (!toAddress || !tokenAmount) { + throw new Error('Invalid transaction parameters'); + } + + const isNative = tokenCaipId === Networks[scope].nativeToken.caip19Id; + + const builder = isNative ? sendSolBuilder : sendSplTokenBuilder; + const feeCalculator = new SendFeeCalculator(builder); + + const params = { + from: account, + to: address(toAddress), + amount: tokenAmount, + network: scope, + ...(isNative + ? {} + : { mint: address(parseCaipAssetType(tokenCaipId).assetReference) }), + }; + + const transactionMessage = await builder.buildTransactionMessage(params); + + const base64EncodedTransactionMessage = + await fromCompilableTransactionMessageToBase64String(transactionMessage); + + const feeInLamports = feeCalculator.getFee(); + + return { + feeInLamports, + base64EncodedTransactionMessage, + }; +}; + +/** + * Builds a transaction message and performs the various interface updates along with it. + * + * WARNING: Do not use this function directly. Use `buildTransactionMessageAndUpdateInterface` instead. + * + * @param interfaceId - The interface ID. + * @param context - The send context. + * @returns A promise that resolves to the transaction message. + */ +// eslint-disable-next-line camelcase, @typescript-eslint/naming-convention +export const buildTransactionMessageAndUpdateInterface_INTERNAL = async ( + interfaceId: string, + context: SendContext, +) => { + try { + if (!sendFieldsAreValid(context)) { + return; + } + + const contextUpdatesInitial: Partial = { + buildingTransaction: true, + transactionMessage: null, + feeEstimatedInSol: null, + }; + + await updateInterface( + interfaceId, + , + { ...context, ...contextUpdatesInitial }, + ); + + const { feeInLamports, base64EncodedTransactionMessage } = + await buildTransactionMessage(context); + + const contextUpdatesAfterBuild: Partial = { + transactionMessage: base64EncodedTransactionMessage, + feeEstimatedInSol: feeInLamports + ? lamportsToSol(feeInLamports).toString() + : null, + buildingTransaction: false, + }; + + // Check if context exists in case the UI was closed while building the transaction + const latestContext = await getInterfaceContext(interfaceId); + + if (!latestContext) { + return; + } + + await updateInterface( + interfaceId, + , + { ...latestContext, ...contextUpdatesAfterBuild }, + ); + } catch (error) { + logger.error('Could not build the send transaction', error); + + const contextUpdatesAfterError: Partial = { + error: { + title: 'send.simulationTitleError', + message: 'send.simulationMessageError', + }, + transactionMessage: null, + feeEstimatedInSol: null, + buildingTransaction: false, + }; + + // Check if context exists in case the UI was closed + const latestContext = await getInterfaceContext(interfaceId); + + if (!latestContext) { + return; + } + + await updateInterface( + interfaceId, + , + { ...latestContext, ...contextUpdatesAfterError }, + ); + } +}; + +export const buildTransactionMessageAndUpdateInterface = pipe( + buildTransactionMessageAndUpdateInterface_INTERNAL, + withoutConcurrency, // Prevent concurrent executions (a new execution cancels the previous) + debounce(500), // Debounce the function to prevent spamming +); diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.test.ts b/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.test.ts new file mode 100644 index 00000000..0db8c4f0 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.test.ts @@ -0,0 +1,101 @@ +import { KnownCaip19Id, Network } from '../../../core/constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../../core/test/mocks/solana-keyring-accounts'; +import type { AssetEntity } from '../../../entities'; +import { getBalancesInScope } from './getBalancesInScope'; + +describe('getBalancesInScope', () => { + const mockAssetEntities = [ + // Account 0 + // Native SOL, Zero balance - ✅ + { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }, + // Native SOL, Different scope - ❌ + { + assetType: KnownCaip19Id.SolDevnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Devnet, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000', + uiAmount: '1', + }, + // SPL token, Non-zero balance - ✅ + { + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + symbol: 'USDC', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + }, + + // Account 1 + // SPL token, Non-zero balance, Different scope - ❌ + { + assetType: KnownCaip19Id.UsdcDevnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + network: Network.Devnet, + decimals: 6, + rawAmount: '1000000', + symbol: 'USDC', + }, + // SPL token, Zero balance - ❌ + { + assetType: KnownCaip19Id.EurcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + network: Network.Mainnet, + decimals: 6, + rawAmount: '0', + symbol: 'EURC', + }, + // SPL token, Zero balance, Different scope - ❌ + { + assetType: KnownCaip19Id.EurcDevnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, + network: Network.Devnet, + decimals: 6, + rawAmount: '1000000', + symbol: 'EURC', + }, + ] as AssetEntity[]; + + it('returns balances for the given scope without 0 balances', () => { + const result = getBalancesInScope(Network.Mainnet, mockAssetEntities); + + expect(result).toStrictEqual({ + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolMainnet]: { + amount: '0', + unit: 'SOL', + }, + [KnownCaip19Id.UsdcMainnet]: { + amount: '1', + unit: 'USDC', + }, + }, + }); + }); + + it('should handle accounts with no tokens', () => { + const result = getBalancesInScope(Network.Mainnet, []); + + expect(result).toStrictEqual({}); + }); + + it('should handle empty balances', () => { + const result = getBalancesInScope(Network.Mainnet, []); + + expect(result).toStrictEqual({}); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.ts b/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.ts new file mode 100644 index 00000000..121bc2a4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/getBalancesInScope.ts @@ -0,0 +1,48 @@ +import type { Balance } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/utils'; + +import { Networks } from '../../../core/constants/solana'; +import type { Network } from '../../../core/constants/solana'; +import type { AccountId } from '../../../core/services/state/State'; +import type { AssetEntity } from '../../../entities'; + +/** + * Given the balances of all accounts, which includes tokens from all scopes, + * return the balances of the given scope and filters them out if they have a zero balance. + * This is used to display the balances in the send flow because we only want to show the tokens + * that the user can send OR an empty SOL balance. + * + * @param scope - The scope to get the balances for. + * @param assetEntities - The asset entities of all accounts. + * @returns The balances of the given scope. + */ +export function getBalancesInScope( + scope: Network, + assetEntities: AssetEntity[], +): Record> { + return assetEntities + .filter((item) => { + /** + * The tokens we can send are: + * - The native token, which can be 0 for display purposes. + * - All tokens for the given scope with a non-zero balance. + */ + const isNativeToken = + item.assetType === Networks[scope].nativeToken.caip19Id; + + const isInScope = item.assetType.startsWith(scope); + const hasNonZeroBalance = item.rawAmount !== '0'; + + return isNativeToken || (isInScope && hasNonZeroBalance); + }) + .reduce>>((acc, item) => { + acc[item.keyringAccountId] = { + ...acc[item.keyringAccountId], + [item.assetType]: { + unit: item.symbol, + amount: item.uiAmount, + }, + }; + return acc; + }, {}); +} diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.test.ts b/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.test.ts new file mode 100644 index 00000000..795a8eec --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.test.ts @@ -0,0 +1,65 @@ +import { isSolanaDomain } from './isSolanaDomain'; + +describe('isSolanaDomain', () => { + describe('when given a valid Solana domain', () => { + it('returns true for domain ending with .sol', () => { + expect(isSolanaDomain('example.sol')).toBe(true); + }); + + it('returns true for subdomain ending with .sol', () => { + expect(isSolanaDomain('sub.example.sol')).toBe(true); + }); + + it('returns true for domain with numbers ending with .sol', () => { + expect(isSolanaDomain('test123.sol')).toBe(true); + }); + + it('returns true for domain with hyphens ending with .sol', () => { + expect(isSolanaDomain('my-domain.sol')).toBe(true); + }); + }); + + describe('when given an invalid domain', () => { + it('returns false for domain ending with .com', () => { + expect(isSolanaDomain('example.com')).toBe(false); + }); + + it('returns false for domain ending with .org', () => { + expect(isSolanaDomain('example.org')).toBe(false); + }); + + it('returns false for domain without extension', () => { + expect(isSolanaDomain('example')).toBe(false); + }); + + it('returns false for domain ending with .solx', () => { + expect(isSolanaDomain('example.solx')).toBe(false); + }); + + it('returns false for domain with .sol in the middle', () => { + expect(isSolanaDomain('example.sol.com')).toBe(false); + }); + }); + + describe('when given edge cases', () => { + it('returns false for null input', () => { + expect(isSolanaDomain(null)).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isSolanaDomain('')).toBe(false); + }); + + it('returns false for string with only .sol', () => { + expect(isSolanaDomain('.sol')).toBe(false); + }); + + it('returns false for string ending with .SOL (uppercase)', () => { + expect(isSolanaDomain('example.SOL')).toBe(false); + }); + + it('returns false for string ending with .Sol (mixed case)', () => { + expect(isSolanaDomain('example.Sol')).toBe(false); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.ts b/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.ts new file mode 100644 index 00000000..8c945d59 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/utils/isSolanaDomain.ts @@ -0,0 +1,20 @@ +/** + * Checks if a domain is a Solana domain (.sol). + * + * @param domain - The domain to check. + * @returns `true` if the domain is a valid domain ending with `.sol`, `false` otherwise. Returns `false` if the domain is `null`. + */ +export function isSolanaDomain(domain: string | null): boolean { + if (!domain) { + return false; + } + + // Regex to match valid domain ending with .sol + // Allows letters, numbers, hyphens, but not starting/ending with hyphen + // Must have at least one character before .sol + // Supports subdomains (e.g., sub.example.sol) + const solanaDomainRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.sol$/u; + + return solanaDomainRegex.test(domain); +} diff --git a/merged-packages/solana-wallet-snap/src/features/send/validation/form.test.ts b/merged-packages/solana-wallet-snap/src/features/send/validation/form.test.ts new file mode 100644 index 00000000..3a67875e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/validation/form.test.ts @@ -0,0 +1,318 @@ +import { KnownCaip19Id, Network } from '../../../core/constants/solana'; +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, +} from '../../../core/test/mocks/solana-keyring-accounts'; +import type { FieldValidationFunction } from '../../../core/types/form'; +import type { SendContext } from '../types'; +import { SendCurrencyType } from '../types'; +import { + amountInput, + required, + sendFieldsAreValid, + validateField, +} from './form'; + +describe('send form validation', () => { + describe('validateField', () => { + const validation: Partial> = { + test: [required('send.fromRequiredError')], + }; + + it('returns no error for a valid account', () => { + const result = validateField('test', 'validAccount', validation); + expect(result).toBeNull(); + }); + + it('returns required error for an empty account', () => { + const result = validateField('test', '', validation); + expect(result).toStrictEqual({ + message: 'Account is required', + value: '', + }); + }); + + it('returns null if no validation functions are provided', () => { + const result = validateField('', '', {}); + expect(result).toBeNull(); + }); + }); + + describe('sendFieldsAreValid', () => { + it('returns true when all fields are valid', () => { + const context = { + preferences: { + locale: 'en', + }, + tokenCaipId: KnownCaip19Id.SolTestnet, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '100', + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '123', // sender has 123 SOL, more than the amount to send + }, + }, + }, + currencyType: SendCurrencyType.TOKEN, + minimumBalanceForRentExemptionSol: '0.002', + scope: Network.Testnet, + } as unknown as SendContext; + + const result = sendFieldsAreValid(context); + expect(result).toBe(true); + }); + + it('returns false when any field is invalid', () => { + const context = { + preferences: { + locale: 'en', + }, + tokenCaipId: KnownCaip19Id.SolTestnet, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '', // amount is invalid + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '123', + }, + }, + }, + currencyType: SendCurrencyType.TOKEN, + minimumBalanceForRentExemptionSol: '0.002', + scope: Network.Testnet, + } as unknown as SendContext; + + const result = sendFieldsAreValid(context); + expect(result).toBe(false); + }); + + it('returns false when the amount is greater than the balance', () => { + const contextWithInvalidBalance = { + preferences: { + locale: 'en', + }, + tokenCaipId: KnownCaip19Id.SolTestnet, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + toAddress: MOCK_SOLANA_KEYRING_ACCOUNT_1.address, + amount: '100', + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '5', // sender has 5 SOL, but we're trying to send 100 + }, + }, + }, + currencyType: SendCurrencyType.TOKEN, + minimumBalanceForRentExemptionSol: '0.002', + scope: Network.Testnet, + } as unknown as SendContext; + + const result = sendFieldsAreValid(contextWithInvalidBalance); + expect(result).toBe(false); + }); + }); + + describe('amountInput', () => { + describe('when sending native SOL', () => { + const createSolContext = (overrides = {}) => + ({ + preferences: { locale: 'en' }, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + tokenCaipId: KnownCaip19Id.SolTestnet, + minimumBalanceForRentExemptionSol: '0.002', + scope: Network.Testnet, + currencyType: SendCurrencyType.TOKEN, + feeEstimatedInSol: '0.000005', + selectedTokenMetadata: { + symbol: 'SOL', + }, + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '1', // Sender has 1 SOL by default + }, + }, + }, + ...overrides, + }) as unknown as SendContext; + + it('returns an error with no message when the amount is empty string', () => { + const context = createSolContext(); + const validator = amountInput(context); + expect(validator('')).toStrictEqual({ + message: '', + value: '', + }); + }); + + it('returns null when the amount is valid', () => { + const context = createSolContext(); + const validator = amountInput(context); + expect(validator('0.5')).toBeNull(); + }); + + it('returns an error with no message when the amount is 0', () => { + const context = createSolContext(); + const validator = amountInput(context); + expect(validator('0')).toStrictEqual({ + message: 'Amount must be greater than 0.002', + value: '0', + }); + }); + + it('returns an error when the amount is greater than the balance', () => { + const context = createSolContext(); + const validator = amountInput(context); + expect(validator('1.5')).toStrictEqual({ + message: 'Insufficient balance: 1 SOL', + value: '1.5', + }); + }); + + it('returns an error when the amount is less than the minimum balance for rent exemption', () => { + const context = createSolContext(); + const validator = amountInput(context); + expect(validator('0.001')).toStrictEqual({ + message: 'Amount must be greater than 0.002', + value: '0.001', + }); + }); + + it('considers transaction fees and rent exemption when validating balance', () => { + const context = createSolContext({ + feeEstimatedInSol: '0.1', + minimumBalanceForRentExemptionSol: '0.05', + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolTestnet]: { + amount: '1', + }, + }, + }, + }); + + const validator = amountInput(context); + // 0.9 + 0.1 fee + 0.05 rent exemption > 1 SOL balance + expect(validator('0.9')).toStrictEqual({ + message: 'Insufficient SOL balance to cover the transaction fee', + value: '0.9', + }); + + // 0.8 + 0.1 fee + 0.05 rent exemption < 1 SOL balance (0.95 total) + expect(validator('0.8')).toBeNull(); + }); + }); + + describe('when sending SPL token', () => { + const createSplContext = (overrides = {}) => + ({ + preferences: { locale: 'en' }, + fromAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + tokenCaipId: KnownCaip19Id.EurcDevnet, + minimumBalanceForRentExemptionSol: '0.002', + scope: Network.Testnet, + currencyType: SendCurrencyType.TOKEN, + feeEstimatedInSol: '0.000005', + selectedTokenMetadata: { + symbol: 'EURC', + }, + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.EurcDevnet]: { + amount: '100', // Sender has 100 EURC by default + }, + [KnownCaip19Id.SolTestnet]: { + amount: '1', // Sender has 1 SOL for fees by default + }, + }, + }, + ...overrides, + }) as unknown as SendContext; + + it('returns an error with no message when the amount is empty string', () => { + const context = createSplContext(); + const validator = amountInput(context); + expect(validator('')).toStrictEqual({ + message: '', + value: '', + }); + }); + + it('returns null when the amount is valid', () => { + const context = createSplContext(); + const validator = amountInput(context); + expect(validator('50')).toBeNull(); + }); + + it('returns an error with no message when the amount is 0', () => { + const context = createSplContext(); + const validator = amountInput(context); + expect(validator('0')).toStrictEqual({ + message: '', + value: '0', + }); + }); + + it('does not compare the amount to the minimum balance for rent exemption', () => { + const context = createSplContext(); + const validator = amountInput(context); + expect(validator('0.001')).toBeNull(); + }); + + it('returns an error when the token amount is greater than the balance', () => { + const context = createSplContext(); + const validator = amountInput(context); + expect(validator('150')).toStrictEqual({ + message: 'Insufficient balance: 100 EURC', + value: '150', + }); + }); + + it('returns an error when SOL balance is insufficient for fees', () => { + const context = createSplContext({ + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.EurcDevnet]: { + amount: '100', + }, + [KnownCaip19Id.SolTestnet]: { + amount: '0.000001', // Not enough SOL for fees + }, + }, + }, + feeEstimatedInSol: '0.00001', + }); + + const validator = amountInput(context); + expect(validator('50')).toStrictEqual({ + message: 'Insufficient SOL balance to cover the transaction fee', + value: '50', + }); + }); + + it('returns an error when paying the fee would drop the balance below the minimum balance for rent exemption', () => { + const context = createSplContext({ + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.EurcDevnet]: { + amount: '100', + }, + [KnownCaip19Id.SolTestnet]: { + amount: '0.002', // Enough SOL to pay for the fee, but balance would drop below the rent (fee = 0.000005 SOL, rent exemption = 0.002 SOL) + }, + }, + }, + }); + + const validator = amountInput(context); + expect(validator('50')).toStrictEqual({ + message: 'Insufficient SOL balance to cover the transaction fee', + value: '50', + }); + }); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/validation/form.ts b/merged-packages/solana-wallet-snap/src/features/send/validation/form.ts new file mode 100644 index 00000000..3e912741 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/validation/form.ts @@ -0,0 +1,247 @@ +import { address as addressValidator } from '@solana/kit'; + +import type { + FieldValidationFunction, + ValidationFunction, +} from '../../../core/types/form'; +import { solToLamports } from '../../../core/utils/conversion'; +import { i18n } from '../../../core/utils/i18n'; +import type { Locale, LocalizedMessage } from '../../../core/utils/i18n'; +import { + getBalance, + getIsNativeToken, + getNativeTokenBalance, + getTokenAmount, +} from '../selectors'; +import type { SendContext } from '../types'; +import { SendFormNames } from '../types'; +import { isSolanaDomain } from '../utils/isSolanaDomain'; +import { validation } from '../views/SendForm/validation'; + +/** + * Validates a field value based on the provided validation functions. + * + * @param name - The name of the field. + * @param value - The value of the field. + * @param _validation - An object containing validation functions for each field. + * @returns The first validation error found, or null if no errors. + */ +export function validateField( + name: FieldNames, + value: string, + _validation: Partial>, +) { + if (!_validation[name]) { + return null; + } + + return ( + _validation[name] + ?.map((validator) => { + return validator(value); + }) + .find((result) => result !== null) ?? null + ); +} + +/** + * Validates if all fields set in the form are valid. + * + * @param context - The send context, where values are read from. + * @returns True if all fields are valid, otherwise false. + */ +export function sendFieldsAreValid(context: SendContext): boolean { + const allValidators = validation(context); + + const values: Partial> = { + [SendFormNames.SourceAccountSelector]: context.fromAccountId, + [SendFormNames.AmountInput]: context.amount, + [SendFormNames.DestinationAccountInput]: context.toAddress, + }; + + const isAllValidatorsValid = Object.entries(allValidators).every( + ([field, fieldValidation]) => { + const value = values[field as SendFormNames]; + if (!value) { + return false; + } + return fieldValidation.every((validator) => validator(value) === null); + }, + ); + + return isAllValidatorsValid; +} + +/** + * Validates that the given value is a string. + * + * @param message - The error message to return if validation fails. + * @param locale - The locale of the message. + * @returns True if the value is valid, otherwise false. + */ +export const required: ValidationFunction = ( + message: LocalizedMessage, + locale: Locale, +) => { + const translate = i18n(locale); + + return (value: string) => { + const error = value === '' ? { message: translate(message), value } : null; + return error ? { message: error.message, value } : null; + }; +}; + +export const addressOrDomain: ValidationFunction = ( + message: LocalizedMessage, + locale: Locale, +) => { + const translate = i18n(locale); + + return (value: string) => { + try { + if (isSolanaDomain(value)) { + return null; + } + + addressValidator(value); + return null; + } catch { + return { message: translate(message), value }; + } + }; +}; + +/** + * Validates that the given value is a valid Solana address. + * + * @param message - The error message to return if validation fails. + * @param locale - The locale of the message. + * @returns True if the value is valid, otherwise an object with the error message. + */ +export const address: ValidationFunction = ( + message: LocalizedMessage, + locale: Locale, +) => { + const translate = i18n(locale); + + return (value: string) => { + try { + addressValidator(value); + return null; + } catch { + return { message: translate(message), value }; + } + }; +}; + +/** + * Custom validation logic for the amount input field. + * + * It's invalid when: + * - The value parses to 0. + * - The user is sending SOL, and the amount is lower than the minimum balance for rent exemption. + * - The amount + fee is greater than the balance. + * + * @param context - The send context, where values are read from. + * @returns True if the value is valid, otherwise an object with the error message. + */ +export const amountInput = (context: SendContext) => { + const { + minimumBalanceForRentExemptionSol, + preferences: { locale }, + feeEstimatedInSol, + selectedTokenMetadata, + } = context; + const translate = i18n(locale); + + return (value: string) => { + // If the value is empty string, it's invalid but we don't want to show an error + if (value === '') { + return { message: '', value }; + } + + const tokenAmount = getTokenAmount({ ...context, amount: value }); + const tokenAmountLamports = solToLamports(tokenAmount ?? '0'); + + const balance = getBalance(context); + const balanceLamports = solToLamports(balance); + + const feeEstimatedInLamports = solToLamports(feeEstimatedInSol ?? '0'); + + const minimumBalanceForRentExemptionLamports = solToLamports( + minimumBalanceForRentExemptionSol ?? '0', + ); + const solBalance = getNativeTokenBalance(context); + const solBalanceLamports = solToLamports(solBalance); + const isNativeToken = getIsNativeToken(context); + + // If you try to send more than your balance, it's invalid + const isAmountGreaterThanBalance = tokenAmountLamports.gt(balanceLamports); + if (isAmountGreaterThanBalance) { + return { + message: `${translate('send.insufficientBalance')}: ${balance} ${selectedTokenMetadata?.symbol ?? ''}`, + value, + }; + } + + if (isNativeToken) { + // If the value is lower than the minimum balance for rent exemption, it's invalid + const valueLowerThanMinimum = tokenAmountLamports.lt( + minimumBalanceForRentExemptionLamports, + ); + + if (valueLowerThanMinimum) { + return { + message: translate( + 'send.amountGreatherThanMinimumBalanceForRentExemptionError', + { + minimumValue: minimumBalanceForRentExemptionSol, + }, + ), + value, + }; + } + + // If the (amount + fee + minimum balance for rent exemption) is greater than the balance, it's invalid + const isAmountPlusFeePlusRentExemptionGreaterThanBalance = + tokenAmountLamports + .plus(feeEstimatedInLamports) + .plus(minimumBalanceForRentExemptionLamports) + .gt(balanceLamports); + + if (isAmountPlusFeePlusRentExemptionGreaterThanBalance) { + return { + message: translate('send.insuffientSolToCoverFee'), + value, + }; + } + } else { + // If user has less SOL than the fee + rent, it's invalid. Even if the user has enough SOL to pay for the fee, the balance after tx cannot drop below the rent. + const isSolBalanceLowerThanFeePlusRent = feeEstimatedInLamports + .plus(minimumBalanceForRentExemptionLamports) + .gt(solBalanceLamports); + + if (isSolBalanceLowerThanFeePlusRent) { + return { + message: translate('send.insuffientSolToCoverFee'), + value, + }; + } + } + + // If the value parses to 0, it's invalid but we don't want to show an error + if (tokenAmountLamports.isZero()) { + return { message: '', value }; + } + + // If you have 0 SOL, you can't pay for the fee, it's invalid + if (solBalanceLamports.isZero()) { + return { + message: translate('send.insuffientSolToCoverFee'), + value, + }; + } + + return null; + }; +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/SendForm.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/SendForm.tsx new file mode 100644 index 00000000..f2f76574 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/SendForm.tsx @@ -0,0 +1,259 @@ +import { + AccountSelector, + AssetSelector, + Banner, + Box, + Button, + Container, + Field, + Footer, + Form, + Text, +} from '@metamask/snaps-sdk/jsx'; +import { isNullOrUndefined } from '@metamask/utils'; +import type { Caip10Address } from 'src/core/constants/solana'; + +import { Navigation } from '../../../../core/components/Navigation/Navigation'; +import { addressToCaip10 } from '../../../../core/utils/addressToCaip10'; +import { formatCrypto } from '../../../../core/utils/formatCrypto'; +import { formatFiat } from '../../../../core/utils/formatFiat'; +import { i18n } from '../../../../core/utils/i18n'; +import { tokenToFiat } from '../../../../core/utils/tokenToFiat'; +import { AmountInput } from '../../components/AmountInput/AmountInput'; +import { ToAddressField } from '../../components/ToAddressField/ToAddressField'; +import { getSelectedTokenPrice } from '../../selectors'; +import { SendCurrencyType, SendFormNames } from '../../types'; +import type { SendContext } from '../../types'; + +type SendFormProps = { + context: SendContext; + inputToAddress?: string; + inputAmount?: string; +}; + +export const SendForm = ({ + context, + inputToAddress, + inputAmount, +}: SendFormProps) => { + const { + accounts, + fromAccountId, + amount, + destinationAddressOrDomain, + domainResolutionStatus, + validation, + currencyType, + tokenCaipId, + scope, + balances, + tokenPricesFetchStatus, + buildingTransaction, + error, + loading, + transactionMessage, + preferences: { locale, currency }, + } = context; + + const translate = i18n(locale); + const selectedToken = balances[fromAccountId]?.[tokenCaipId]; + const tokenBalance = selectedToken?.amount; + const tokenSymbol = selectedToken?.unit ?? ''; + const isBalanceDefined = tokenBalance !== undefined; + + const selectedAccount = accounts.find( + (account) => account.id === fromAccountId, + ); + const selectedAccountAddress: Caip10Address | undefined = + selectedAccount?.address + ? addressToCaip10(scope, selectedAccount.address) + : undefined; + + const selectedTokenPrice = getSelectedTokenPrice(context); + + const balanceUndefinedOrZero = + tokenBalance === undefined || tokenBalance === '0'; + const selectedTokenPriceUnavailable = selectedTokenPrice === undefined; + const showTokenPriceMessage = + tokenPricesFetchStatus === 'error' || + (tokenPricesFetchStatus === 'fetched' && selectedTokenPriceUnavailable); + + const currencyToBalance: Record = isBalanceDefined + ? { + [SendCurrencyType.FIAT]: formatFiat( + tokenToFiat(tokenBalance, selectedTokenPrice ?? 0), + currency, + locale, + ), + [SendCurrencyType.TOKEN]: formatCrypto( + tokenBalance, + tokenSymbol, + locale, + ), + } + : { + [SendCurrencyType.FIAT]: '', + [SendCurrencyType.TOKEN]: '', + }; + + const balance = currencyToBalance[currencyType]; + + const canPickAmout = + fromAccountId.length > 0 && + (destinationAddressOrDomain + ? destinationAddressOrDomain.length > 0 + : false) && + isNullOrUndefined(validation?.[SendFormNames.DestinationAccountInput]); + + const isTransactionMessageSuccessfullyBuild = + !isNullOrUndefined(transactionMessage) && transactionMessage !== ''; + + const showClearAddressButton = Boolean( + destinationAddressOrDomain && destinationAddressOrDomain.length > 0, + ); + + const amountInputIsError = Boolean( + validation?.[SendFormNames.AmountInput]?.message, + ); + + const balanceText = amountInputIsError + ? (validation?.[SendFormNames.AmountInput]?.message ?? '') + : `${translate('send.balance')}: ${balance}`; + + const canReview = + fromAccountId.length > 0 && + (amount ? amount.length > 0 : false) && + (destinationAddressOrDomain + ? destinationAddressOrDomain.length > 0 + : false) && + Object.values(validation).every(isNullOrUndefined) && + isBalanceDefined && + !buildingTransaction && + isTransactionMessageSuccessfullyBuild; + + return ( + + + +
+ {showTokenPriceMessage && ( + + + {translate('send.selectedTokenPriceNotAvailable', { + currency, + })} + + + )} + {null} + {null} + {null} + + + + + + {null} + {null} + {null} + + {canPickAmout && ( + + {null} + {null} + {null} + + + + + + + + + + {balanceText} + + + + + + )} + {error && ( + + {null} + + {translate(error.message)} + + + )} + +
+
+ + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.test.ts b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.test.ts new file mode 100644 index 00000000..fb266ccb --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.test.ts @@ -0,0 +1,385 @@ +import { lamports } from '@solana/kit'; +import BigNumber from 'bignumber.js'; +import type { Preferences } from 'src/core/types/snap'; + +import { + KnownCaip19Id, + LAMPORTS_PER_SOL, + Network, +} from '../../../../core/constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../../core/test/mocks/solana-keyring-accounts'; +import { solToLamports } from '../../../../core/utils/conversion'; +import { updateInterface } from '../../../../core/utils/interface'; +import { keyring } from '../../../../snapContext'; +import type { SendContext } from '../../types'; +import { SendCurrencyType, SendFormNames } from '../../types'; +import { amountInput } from '../../validation/form'; +import { eventHandlers } from './events'; + +jest.mock('../../../../core/utils/interface'); + +const mockId = 'test-id'; +const mockAccount = MOCK_SOLANA_KEYRING_ACCOUNT_0; +const mockToAddress = 'destination-address'; +const mockBalanceInSol = '1.5'; // 1.5 SOL +const mockSolPrice = '20'; // $20 per SOL +const mockBaseFeeInLamports = lamports(5000n); // 0.000005 SOL +const mockPriorityFeeInLamports = lamports(4n); // Integer(450 * 10000n / MICRO_LAMPORTS_PER_LAMPORTS) +const mockMinimumBalanceForRentExemptionSol = '0.002'; +const mockMinimumBalanceForRentExemptionLamports = solToLamports( + mockMinimumBalanceForRentExemptionSol, +); + +const setupTest = (overrides: Partial = {}): SendContext => { + const baseContext: SendContext = { + fromAccountId: mockAccount.id, + toAddress: mockToAddress, + fromDomain: null, + toDomain: null, + balances: { + [mockAccount.id]: { + [KnownCaip19Id.SolLocalnet]: { + amount: mockBalanceInSol, + unit: 'SOL', + }, + }, + }, + destinationAddressOrDomain: mockToAddress, + domainResolutionStatus: null, + scope: Network.Localnet, + tokenPricesFetchStatus: 'initial', + tokenPrices: { + [KnownCaip19Id.SolLocalnet]: { + id: 'solana', + price: Number(mockSolPrice), + marketCap: 60616788101.97389, + allTimeHigh: 271.88956967253705, + allTimeLow: 0.4642275012156975, + totalVolume: 3363833590.4901085, + high1d: 120.27435704548662, + low1d: 114.61984006685489, + circulatingSupply: 512506456.9100605, + dilutedMarketCap: 70673814622.89714, + marketCapPercentChange1d: 2.65784, + priceChange1d: 3.76, + pricePercentChange1h: -0.1991040003642417, + pricePercentChange1d: 3.0304256401948635, + pricePercentChange7d: -10.23628144764743, + pricePercentChange14d: 3.420785507329618, + pricePercentChange30d: -10.774354508031294, + pricePercentChange200d: -3.649484892677309, + pricePercentChange1y: -34.78708964545803, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + }, + validation: {}, + amount: '', + accounts: [], + feeEstimatedInSol: '0', + currencyType: SendCurrencyType.TOKEN, + transaction: null, + stage: 'send-form', + preferences: { + locale: 'en', + currency: 'USD', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: false, + simulateOnChainActions: false, + useTokenDetection: false, + batchCheckBalances: false, + displayNftMedia: false, + useNftDetection: false, + }, + feePaidInSol: '0', + tokenCaipId: KnownCaip19Id.SolLocalnet, + assets: [KnownCaip19Id.SolLocalnet], + selectedTokenMetadata: null, + buildingTransaction: false, + transactionMessage: null, + error: null, + minimumBalanceForRentExemptionSol: mockMinimumBalanceForRentExemptionSol, + loading: true, + }; + + return { + ...baseContext, + ...overrides, + accounts: overrides.accounts ?? baseContext.accounts, + assets: overrides.assets ?? baseContext.assets, + balances: overrides.balances ?? baseContext.balances, + preferences: { + ...baseContext.preferences, + ...overrides.preferences, + }, + tokenPrices: overrides.tokenPrices ?? baseContext.tokenPrices, + validation: overrides.validation ?? baseContext.validation, + }; +}; + +describe('SendForm events', () => { + describe('onSwapCurrencyButtonClick', () => { + it('swaps the currency type', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.TOKEN, + }); + + await eventHandlers[SendFormNames.SwapCurrencyButton]({ + id: mockId, + context, + }); + + expect(context.currencyType).toBe(SendCurrencyType.FIAT); + }); + + it('does not update the amount if it is empty', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.TOKEN, + amount: '', + }); + + await eventHandlers[SendFormNames.SwapCurrencyButton]({ + id: mockId, + context, + }); + + expect(context.amount).toBe(''); + }); + + it('updates the amount if it is not empty', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.TOKEN, + amount: '1', + }); + + await eventHandlers[SendFormNames.SwapCurrencyButton]({ + id: mockId, + context, + }); + + expect(context.amount).toBe(mockSolPrice); + }); + }); + + describe('onMaxAmountButtonClick', () => { + beforeEach(() => { + jest.spyOn(keyring, 'getAccountOrThrow').mockResolvedValue(mockAccount); + + (updateInterface as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calculates max amount in SOL correctly', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.TOKEN, + }); + + await eventHandlers[SendFormNames.MaxAmountButton]({ + id: mockId, + context, + }); + + // Expected SOL amount: (1.5 SOL * LAMPORTS_PER_SOL - 5000 - 4 - 2000 - 1) / LAMPORTS_PER_SOL + const expectedAmount = BigNumber(mockBalanceInSol) + .multipliedBy(LAMPORTS_PER_SOL) + .minus(mockBaseFeeInLamports.toString()) + .minus(mockPriorityFeeInLamports.toString()) + .minus(mockMinimumBalanceForRentExemptionLamports) + .minus(1) + .dividedBy(LAMPORTS_PER_SOL) + .toString(); + + expect(updateInterface).toHaveBeenCalledWith( + mockId, + expect.anything(), + expect.objectContaining({ + amount: expectedAmount, + }), + ); + }); + + it('calculates max amount in SOL correctly including rounding', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.TOKEN, + balances: { + [mockAccount.id]: { + [KnownCaip19Id.SolLocalnet]: { + amount: '1.00999', + unit: 'SOL', + }, + }, + }, + minimumBalanceForRentExemptionSol: '0.00089088', + }); + + await eventHandlers[SendFormNames.MaxAmountButton]({ + id: mockId, + context, + }); + + const expectedAmount = '1.009094115'; + expect(updateInterface).toHaveBeenCalledWith( + mockId, + expect.anything(), + expect.objectContaining({ + amount: expectedAmount, + }), + ); + + // This is a hack to test that the transaction would actually be sendable after + expect( + amountInput({ ...context, feeEstimatedInSol: '0.000005' })( + expectedAmount, + ), + ).toBeNull(); + }); + + it('calculates max amount in FIAT correctly', async () => { + const context = setupTest({ + currencyType: SendCurrencyType.FIAT, + }); + + await eventHandlers[SendFormNames.MaxAmountButton]({ + id: mockId, + context, + }); + + // Expected FIAT amount: ((1.5 SOL * LAMPORTS_PER_SOL - 5000 - 4 - 2000 - 1) / LAMPORTS_PER_SOL) * $20 + const expectedAmount = BigNumber(mockBalanceInSol) + .multipliedBy(LAMPORTS_PER_SOL) + .minus(mockBaseFeeInLamports.toString()) + .minus(mockPriorityFeeInLamports.toString()) + .minus(mockMinimumBalanceForRentExemptionLamports) + .minus(1) + .dividedBy(LAMPORTS_PER_SOL) + .multipliedBy(mockSolPrice) + .toString(); + + expect(updateInterface).toHaveBeenCalledWith( + mockId, + expect.anything(), + expect.objectContaining({ + amount: expectedAmount, + }), + ); + }); + }); +}); + +describe('SendForm event tracking', () => { + const setupTrackingTest = async () => { + const trackError = jest.fn().mockResolvedValue('tracked-error-id'); + const updateInterfaceMock = jest.fn().mockResolvedValue(undefined); + const configProvider = { + get: jest.fn().mockReturnValue({ + staticApi: { + baseUrl: 'https://static.example.com', + }, + }), + }; + const nameResolutionService = { + resolveAddress: jest.fn().mockResolvedValue(null), + }; + const priceApiClient = { + getMultipleSpotPrices: jest.fn(), + }; + + jest.doMock('../../../../core/utils/errors', () => ({ + trackError, + })); + jest.doMock('../../../../core/utils/interface', () => ({ + resolveInterface: jest.fn(), + SEND_FORM_INTERFACE_NAME: 'send-form', + updateInterface: updateInterfaceMock, + })); + jest.doMock('../../Send', () => ({ + Send: () => null, + })); + jest.doMock('../../../../snapContext', () => ({ + configProvider, + keyring: { + getAccountOrThrow: jest + .fn() + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), + }, + nameResolutionService, + priceApiClient, + sendSolBuilder: {}, + sendSplTokenBuilder: {}, + state: { + deleteKey: jest.fn().mockResolvedValue(undefined), + }, + })); + + (globalThis as any).snap = { + request: jest.fn().mockResolvedValue(undefined), + }; + + let isolatedEventHandlers: typeof eventHandlers | undefined; + jest.isolateModules(() => { + ({ eventHandlers: isolatedEventHandlers } = + jest.requireActual('./events')); + }); + + if (!isolatedEventHandlers) { + throw new Error('Failed to load eventHandlers'); + } + + return { + eventHandlers: isolatedEventHandlers, + trackError, + priceApiClient, + updateInterfaceMock, + }; + }; + + it('tracks token price failures before confirmation', async () => { + const { + eventHandlers: isolatedEventHandlers, + trackError, + priceApiClient, + updateInterfaceMock, + } = await setupTrackingTest(); + + const error = new Error('Spot prices failed'); + const context = setupTest({ + accounts: [MOCK_SOLANA_KEYRING_ACCOUNT_0], + scope: Network.Mainnet, + tokenCaipId: KnownCaip19Id.SolMainnet, + assets: [KnownCaip19Id.SolMainnet], + balances: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + [KnownCaip19Id.SolMainnet]: { + amount: '2', + unit: 'SOL', + }, + }, + }, + amount: '1', + toAddress: 'FDUGdV6bjhvw5gbirXCvqbTSWK9999kcrZcrHoCQzXJK', + feeEstimatedInSol: '0.000005', + tokenPrices: {}, + preferences: { + currency: 'usd', + useExternalPricingData: true, + } as Preferences, + selectedTokenMetadata: null, + }); + + priceApiClient.getMultipleSpotPrices.mockRejectedValue(error); + + await isolatedEventHandlers[SendFormNames.SendButton]({ + id: 'interface-id', + context, + }); + + expect(trackError).toHaveBeenNthCalledWith(1, error); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.tsx new file mode 100644 index 00000000..406ce839 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/events.tsx @@ -0,0 +1,559 @@ +import type { InputChangeEvent } from '@metamask/snaps-sdk'; +import BigNumber from 'bignumber.js'; +import { merge } from 'lodash'; + +import { METAMASK_ORIGIN, Networks } from '../../../../core/constants/solana'; +import { ScheduleBackgroundEventMethod } from '../../../../core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; +import { SendFeeCalculator } from '../../../../core/services'; +import { buildUrl } from '../../../../core/utils/buildUrl'; +import { + lamportsToSol, + solToLamports, +} from '../../../../core/utils/conversion'; +import { trackError } from '../../../../core/utils/errors'; +import { i18n } from '../../../../core/utils/i18n'; +import { + resolveInterface, + SEND_FORM_INTERFACE_NAME, + updateInterface, +} from '../../../../core/utils/interface'; +import { tokenToFiat } from '../../../../core/utils/tokenToFiat'; +import { + configProvider, + nameResolutionService, + priceApiClient, + sendSolBuilder, + sendSplTokenBuilder, + state, +} from '../../../../snapContext'; +import { getBalance, getIsNativeToken } from '../../selectors'; +import { Send } from '../../Send'; +import { SendCurrencyType, SendFormNames } from '../../types'; +import type { SendContext } from '../../types'; +import { buildTransactionMessageAndUpdateInterface } from '../../utils/buildTransactionMessageAndUpdateInterface'; +import { isSolanaDomain } from '../../utils/isSolanaDomain'; +import { sendFieldsAreValid, validateField } from '../../validation/form'; +import { validation } from './validation'; + +/** + * Handles the click event for the back button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @returns A promise that resolves when the operation is complete. + */ +async function onBackButtonClick({ id }: { id: string }) { + await resolveInterface(id, false); + await state.deleteKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`); +} + +/** + * Handles the change event for the source account selector. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.event - The change event. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onSourceAccountSelectorValueChange({ + id, + event, + context, +}: { + id: string; + event: InputChangeEvent; + context: SendContext; +}) { + const accountData = event.value as { accountId: string; addresses: string[] }; + context.fromAccountId = accountData.accountId; + context.error = null; + + context.validation[SendFormNames.SourceAccountSelector] = + validateField( + SendFormNames.SourceAccountSelector, + context.fromAccountId, + validation(context), + ); + + if (context.amount) { + context.validation[SendFormNames.AmountInput] = + validateField( + SendFormNames.AmountInput, + context.amount, + validation(context), + ); + } + + await updateInterface(id, , context); + + await buildTransactionMessageAndUpdateInterface(id, context); +} + +/** + * Handles the change event for the amount input. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.event - The change event. + * @param params.context - The send context. + */ +async function onAmountInputChange({ + id, + event, + context, +}: { + id: string; + event: InputChangeEvent; + context: SendContext; +}) { + const updatedContext = { ...context }; + + updatedContext.amount = event.value as string; + updatedContext.error = null; + + const amountFieldValidation = validateField( + SendFormNames.AmountInput, + updatedContext.amount, + validation(updatedContext), + ); + + if ( + amountFieldValidation && + updatedContext.validation[SendFormNames.AmountInput]?.message !== + amountFieldValidation?.message + ) { + updatedContext.validation[SendFormNames.AmountInput] = + amountFieldValidation; + await updateInterface( + id, + , + updatedContext, + ); + + return; + } + + if (amountFieldValidation === null) { + updatedContext.validation[SendFormNames.AmountInput] = null; + await updateInterface( + id, + , + updatedContext, + ); + await buildTransactionMessageAndUpdateInterface(id, updatedContext); + } +} + +/** + * Handles the change event for the asset selector. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.event - The change event. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onAssetSelectorValueChange({ + id, + event, + context, +}: { + id: string; + event: InputChangeEvent; + context: SendContext; +}) { + if ( + typeof event.value !== 'object' || + !event.value || + !('asset' in event.value) + ) { + return; + } + + const caipAssetType = event.value.asset; + + const isSameSelectedAsset = context.tokenCaipId === caipAssetType; + + if (isSameSelectedAsset) { + return; + } + + const builder = + caipAssetType === Networks[context.scope].nativeToken.caip19Id + ? sendSolBuilder + : sendSplTokenBuilder; + + const sendFeeCalculator = new SendFeeCalculator(builder); + const feeInLamports = sendFeeCalculator.getFee(); + const feeEstimatedInSol = lamportsToSol(feeInLamports).toString(); + + const contextUpdates: Partial = { + tokenCaipId: caipAssetType, + feeEstimatedInSol, + selectedTokenMetadata: { + symbol: event.value.symbol, + name: event.value.name, + asset: event.value.asset, + imageUrl: null, + }, + amount: '', + error: null, + }; + + const newContext = merge(context, contextUpdates); + + await updateInterface( + id, + , + newContext, + ); + + await buildTransactionMessageAndUpdateInterface(id, context); +} + +/** + * Handles the click event for the swap currency button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + */ +async function onSwapCurrencyButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + context.error = null; + context.currencyType = + context.currencyType === SendCurrencyType.TOKEN + ? SendCurrencyType.FIAT + : SendCurrencyType.TOKEN; + + if (!context.amount) { + // if the amount is empty we dont need to update amount + await updateInterface(id, , context); + return; + } + + const currentAmount = BigNumber(context.amount ?? '0'); + + const { price } = context.tokenPrices[context.tokenCaipId] ?? { price: 0 }; + + // If we switched to TOKEN, divide by currency rate + if (context.currencyType === SendCurrencyType.TOKEN) { + context.amount = currentAmount.dividedBy(price).toString(); + } + + // If the currency is USD, adjust the amount + if (context.currencyType === SendCurrencyType.FIAT) { + context.amount = currentAmount.multipliedBy(price).toString(); + } + + await updateInterface( + id, + , + context, + ); +} + +/** + * Handles the click event for the max amount button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + */ +async function onMaxAmountButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + const { + currencyType, + minimumBalanceForRentExemptionSol, + tokenCaipId, + tokenPrices, + } = context; + const updatedContext: SendContext = { ...context }; + const tokenBalance = getBalance(context); + const isNativeToken = getIsNativeToken(context); + + if (isNativeToken) { + /** + * For a SOL transfer, the maximum amount we can send is the balance minus + * - the base fee + * - the priority fee + * - the minimum balance for rent exemption, because the account cannot fall below this amount + */ + const balanceInLamports = solToLamports(tokenBalance); + + const builder = sendSolBuilder; + + const sendFeeCalculator = new SendFeeCalculator(builder); + const feeInLamports = sendFeeCalculator.getFee().toString(); + + const minimumBalanceForRentExemptionLamports = solToLamports( + minimumBalanceForRentExemptionSol, + ); + + const balanceInLamportsAfterCost = balanceInLamports + .minus(feeInLamports) + .minus(minimumBalanceForRentExemptionLamports) + .minus(1); // Subtract 1 extra lamport to compensate for rounding + + const maxAmountLamports = BigNumber.maximum( + balanceInLamportsAfterCost, + balanceInLamportsAfterCost.isNegative() + ? balanceInLamports + : BigNumber(0), + ); + + const maxAmountSol = lamportsToSol(maxAmountLamports); + + updatedContext.amount = maxAmountSol.toString(); + } else { + updatedContext.amount = tokenBalance; + } + + if (currencyType === SendCurrencyType.FIAT) { + const { price } = tokenPrices[tokenCaipId] ?? { price: 0 }; + updatedContext.amount = tokenToFiat(updatedContext.amount, price); + } + + updatedContext.error = null; + + updatedContext.validation[SendFormNames.AmountInput] = + validateField( + SendFormNames.AmountInput, + updatedContext.amount, + // TODO: This validation should run with the latest fee estimate + validation(updatedContext), + ); + + await updateInterface( + id, + , + updatedContext, + ); + + await buildTransactionMessageAndUpdateInterface(id, updatedContext); +} + +/** + * Handles the change event for the destination account input. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.event - The change event. + * @param params.context - The send context. + */ +async function onDestinationAccountInputValueChange({ + id, + event, + context, +}: { + id: string; + event: InputChangeEvent; + context: SendContext; +}) { + const updatedContext = { ...context }; + + updatedContext.destinationAddressOrDomain = event.value as string; + updatedContext.domainResolutionStatus = null; + updatedContext.error = null; + + const destinationValidation = validateField( + SendFormNames.DestinationAccountInput, + updatedContext.destinationAddressOrDomain, + validation(updatedContext), + ); + + updatedContext.validation[SendFormNames.DestinationAccountInput] = + destinationValidation; + + if (isSolanaDomain(updatedContext?.destinationAddressOrDomain)) { + updatedContext.domainResolutionStatus = 'fetching'; + + await updateInterface( + id, + , + updatedContext, + ); + + // eslint-disable-next-line require-atomic-updates + updatedContext.toAddress = await nameResolutionService + .resolveDomain( + updatedContext.scope, + updatedContext.destinationAddressOrDomain, + ) + .catch(() => { + const translate = i18n(updatedContext.preferences.locale); + updatedContext.validation[SendFormNames.DestinationAccountInput] = { + message: translate('send.toInvalidErrorDomain'), + value: updatedContext.destinationAddressOrDomain ?? '', + }; + + return null; + }); + + updatedContext.domainResolutionStatus = updatedContext.toAddress + ? 'fetched' + : 'error'; + } else { + updatedContext.toAddress = updatedContext.destinationAddressOrDomain; + } + + await updateInterface(id, , updatedContext); + + if (destinationValidation === null) { + await buildTransactionMessageAndUpdateInterface(id, updatedContext); + } +} + +/** + * Handles the click event for the clear button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + */ +async function onClearButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + context.destinationAddressOrDomain = ''; + context.toAddress = ''; + context.domainResolutionStatus = null; + context.error = null; + context.validation[SendFormNames.DestinationAccountInput] = null; + + await updateInterface( + id, + , + context, + ); +} + +/** + * Handles the click event for the cancel button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + */ +async function onCancelButtonClick({ id }: { id: string }) { + await resolveInterface(id, false); + await state.deleteKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`); +} + +/** + * Handles the click event for the send button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onSendButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + const isValid = sendFieldsAreValid(context); + if (!isValid) { + return; + } + + const updatedContext: SendContext = { ...context }; + updatedContext.stage = 'transaction-confirmation'; + updatedContext.loading = true; + + await updateInterface(id, , updatedContext); + + const fromAddress = context.accounts.find( + (account) => account.id === context.fromAccountId, + )?.address; + + const [fromDomain, toDomain, tokenPrices] = await Promise.all([ + fromAddress + ? nameResolutionService.resolveAddress(context.scope, fromAddress) + : null, + context.toAddress + ? nameResolutionService.resolveAddress(context.scope, context.toAddress) + : null, + priceApiClient + .getMultipleSpotPrices(context.assets, context.preferences.currency) + .then((prices) => prices) + .catch(async (error) => { + await trackError(error); + return null; + }), + ]); + + updatedContext.fromDomain = fromDomain; + updatedContext.toDomain = toDomain; + + if (tokenPrices) { + updatedContext.tokenPrices = tokenPrices; + } + + // Build token image URL directly - Snaps SDK now supports URL images + if (context.selectedTokenMetadata) { + const tokenImageUrl = buildUrl({ + baseUrl: configProvider.get().staticApi.baseUrl, + path: '/api/v2/tokenIcons/assets/{assetId}.png', + pathParams: { + assetId: context.selectedTokenMetadata.asset.replace(/:/gu, '/'), + }, + encodePathParams: false, + }); + updatedContext.selectedTokenMetadata = { + ...context.selectedTokenMetadata, + imageUrl: tokenImageUrl, + }; + } + + updatedContext.loading = false; + + await updateInterface(id, , updatedContext); + + // Trigger the side effects that need to happen when the transaction is shown in confirmation UI + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionAdded, + params: { + accountId: context.fromAccountId, + metadata: { + scope: context.scope, + origin: METAMASK_ORIGIN, + }, + }, + }, + }, + }); +} + +export const eventHandlers = { + [SendFormNames.BackButton]: onBackButtonClick, + [SendFormNames.SourceAccountSelector]: onSourceAccountSelectorValueChange, + [SendFormNames.AmountInput]: onAmountInputChange, + [SendFormNames.AssetSelector]: onAssetSelectorValueChange, + [SendFormNames.SwapCurrencyButton]: onSwapCurrencyButtonClick, + [SendFormNames.MaxAmountButton]: onMaxAmountButtonClick, + [SendFormNames.DestinationAccountInput]: onDestinationAccountInputValueChange, + [SendFormNames.ClearButton]: onClearButtonClick, + [SendFormNames.CancelButton]: onCancelButtonClick, + [SendFormNames.SendButton]: onSendButtonClick, +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/validation.ts b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/validation.ts new file mode 100644 index 00000000..17e87b75 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/SendForm/validation.ts @@ -0,0 +1,31 @@ +import { enums, object, optional } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +import { Network } from '../../../../core/constants/solana'; +import type { FieldValidationFunction } from '../../../../core/types/form'; +import { UuidStruct } from '../../../../core/validation/structs'; +import type { SendContext } from '../../types'; +import { SendFormNames } from '../../types'; +import { addressOrDomain, amountInput, required } from '../../validation/form'; + +export const StartSendTransactionFlowParamsStruct = object({ + scope: enums([...Object.values(Network)]), + account: UuidStruct, + assetId: optional(CaipAssetTypeStruct), +}); + +export const validation: ( + context: SendContext, +) => Partial> = (context) => ({ + [SendFormNames.SourceAccountSelector]: [ + required('send.fromRequiredError', context.preferences.locale), + ], + [SendFormNames.AmountInput]: [ + amountInput(context), + required('send.amountRequiredError', context.preferences.locale), + ], + [SendFormNames.DestinationAccountInput]: [ + required('send.toRequiredError', context.preferences.locale), + addressOrDomain('send.toInvalidError', context.preferences.locale), + ], +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/SendPending/SendPending.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/SendPending/SendPending.tsx new file mode 100644 index 00000000..df465c84 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/SendPending/SendPending.tsx @@ -0,0 +1,28 @@ +import { Box, Container } from '@metamask/snaps-sdk/jsx'; + +import { ActionHeader } from '../../../../core/components/ActionHeader/ActionHeader'; +import { i18n } from '../../../../core/utils/i18n'; +import { TransactionDetails } from '../../components/TransactionDetails/TransactionDetails'; +import type { SendContext } from '../../types'; + +export type SendPendingProps = { + context: SendContext; +}; + +export const SendPending = ({ context }: SendPendingProps) => { + const translate = i18n(context.preferences.locale); + + return ( + + + {null} + + + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/TransactionConfirmation.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/TransactionConfirmation.tsx new file mode 100644 index 00000000..680203c4 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/TransactionConfirmation.tsx @@ -0,0 +1,75 @@ +import { Box, Button, Container, Footer } from '@metamask/snaps-sdk/jsx'; + +import { ActionHeader } from '../../../../core/components/ActionHeader/ActionHeader'; +import { Navigation } from '../../../../core/components/Navigation/Navigation'; +import { formatCrypto } from '../../../../core/utils/formatCrypto'; +import { formatFiat } from '../../../../core/utils/formatFiat'; +import { i18n } from '../../../../core/utils/i18n'; +import { tokenToFiat } from '../../../../core/utils/tokenToFiat'; +import { TransactionDetails } from '../../components/TransactionDetails/TransactionDetails'; +import { + getSelectedTokenMetadata, + getSelectedTokenPrice, + getTokenAmount, +} from '../../selectors'; +import type { SendContext } from '../../types'; + +export enum TransactionConfirmationNames { + BackButton = 'transaction-confirmation-back-button', + CancelButton = 'transaction-confirmation-cancel-button', + ConfirmButton = 'transaction-confirmation-submit-button', +} + +type TransactionConfirmationProps = { + context: SendContext; +}; + +export const TransactionConfirmation = ({ + context, +}: TransactionConfirmationProps) => { + const { + preferences: { currency, locale }, + } = context; + const translate = i18n(locale); + + const { tokenImage, tokenSymbol } = getSelectedTokenMetadata(context); + const tokenAmount = getTokenAmount(context); + const selectedTokenPrice = getSelectedTokenPrice(context); + const amountInUserCurrency = + selectedTokenPrice === undefined + ? '' + : formatFiat( + tokenToFiat(tokenAmount ?? '0', selectedTokenPrice), + currency, + locale, + ); + + return ( + + + + + + +
+ + +
+
+ ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.test.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.test.tsx new file mode 100644 index 00000000..185c07da --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.test.tsx @@ -0,0 +1,99 @@ +import { KnownCaip19Id, Network } from '../../../../core/constants/solana'; +import { trackError } from '../../../../core/utils/errors'; +import { updateInterface } from '../../../../core/utils/interface'; +import { keyring, state, walletService } from '../../../../snapContext'; +import { SendCurrencyType } from '../../types'; +import { eventHandlers } from './events'; + +const CONFIRM_BUTTON_NAME = 'transaction-confirmation-submit-button'; + +jest.mock('../../../../core/utils/errors', () => ({ + trackError: jest.fn().mockResolvedValue('tracked-error-id'), +})); + +jest.mock('../../../../core/utils/interface', () => ({ + resolveInterface: jest.fn(), + SEND_FORM_INTERFACE_NAME: 'send-form', + updateInterface: jest.fn(), +})); + +jest.mock('../../Send', () => ({ + Send: () => null, +})); + +jest.mock('./TransactionConfirmation', () => ({ + TransactionConfirmationNames: { + BackButton: 'transaction-confirmation-back-button', + CancelButton: 'transaction-confirmation-cancel-button', + ConfirmButton: 'transaction-confirmation-submit-button', + }, +})); + +jest.mock('../../../../snapContext', () => ({ + keyring: { + getAccountOrThrow: jest.fn(), + }, + state: { + deleteKey: jest.fn(), + }, + walletService: { + signAndSendTransaction: jest.fn(), + }, +})); + +const setupTest = () => { + jest.clearAllMocks(); + (globalThis as any).snap = { + request: jest.fn().mockResolvedValue(undefined), + }; + (keyring.getAccountOrThrow as jest.Mock).mockResolvedValue({ + id: 'account-id', + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + }); + (updateInterface as jest.Mock).mockResolvedValue(undefined); + (state.deleteKey as jest.Mock).mockResolvedValue(undefined); +}; + +describe('TransactionConfirmation events', () => { + it('tracks submission errors and still renders the failure stage', async () => { + setupTest(); + + const error = new Error('Submission failed'); + + (walletService.signAndSendTransaction as jest.Mock).mockRejectedValue( + error, + ); + + await eventHandlers[CONFIRM_BUTTON_NAME]({ + id: 'interface-id', + context: { + scope: Network.Mainnet, + fromAccountId: 'account-id', + feeEstimatedInSol: '0.000005', + transactionMessage: 'transaction-message', + tokenCaipId: KnownCaip19Id.SolMainnet, + currencyType: SendCurrencyType.TOKEN, + tokenPrices: {}, + assets: [], + balances: {}, + preferences: { + locale: 'en', + currency: 'usd', + }, + } as any, + }); + + expect(trackError).toHaveBeenCalledWith(error); + expect(updateInterface).toHaveBeenLastCalledWith( + 'interface-id', + null, + expect.objectContaining({ + stage: 'transaction-failure', + transaction: { + result: 'failure', + signature: null, + }, + }), + ); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.tsx new file mode 100644 index 00000000..d1d1d878 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionConfirmation/events.tsx @@ -0,0 +1,181 @@ +import { METAMASK_ORIGIN } from '../../../../core/constants/solana'; +import { ScheduleBackgroundEventMethod } from '../../../../core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; +import { trackError } from '../../../../core/utils/errors'; +import { + resolveInterface, + SEND_FORM_INTERFACE_NAME, + updateInterface, +} from '../../../../core/utils/interface'; +import logger from '../../../../core/utils/logger'; +import { keyring, state, walletService } from '../../../../snapContext'; +import { Send } from '../../Send'; +import type { SendContext } from '../../types'; +import { TransactionConfirmationNames } from './TransactionConfirmation'; + +/** + * Handles the click event for the back button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onBackButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + const updatedContext: SendContext = { + ...context, + stage: 'send-form', + }; + + await updateInterface( + id, + , + updatedContext, + ); +} + +/** + * Handles the click event for the cancel button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onCancelButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + const { fromAccountId, transactionMessage, scope } = context; + + // Trigger the side effects that need to happen when the transaction is rejected + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionRejected, + params: { + accountId: fromAccountId, + metadata: { + scope, + origin: METAMASK_ORIGIN, + }, + }, + }, + }, + }); + + await resolveInterface(id, false); + await state.deleteKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`); +} + +/** + * Handles the click event for the confirm button. + * + * @param params - The parameters for the function. + * @param params.id - The id of the interface. + * @param params.context - The send context. + * @returns A promise that resolves when the operation is complete. + */ +async function onConfirmButtonClick({ + id, + context, +}: { + id: string; + context: SendContext; +}) { + const { scope, fromAccountId, feeEstimatedInSol, transactionMessage } = + context; + + context.error = null; + + if (!transactionMessage) { + // if we find in this state there is no transaction message, we need to go back to the send form + context.stage = 'send-form'; + + await updateInterface(id, , context); + return; + } + + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT1S', + request: { + method: ScheduleBackgroundEventMethod.OnTransactionApproved, + params: { + accountId: context.fromAccountId, + metadata: { + scope: context.scope, + origin: METAMASK_ORIGIN, + }, + }, + }, + }, + }); + + // First, show the pending stage + const contextPending: SendContext = { + ...context, + stage: 'send-pending', + }; + + await updateInterface(id, , contextPending); + + // Then submit the transaction + let signature: string | null = null; + + try { + const account = await keyring.getAccountOrThrow(fromAccountId); + + const response = await walletService.signAndSendTransaction( + account, + transactionMessage, + scope, + METAMASK_ORIGIN, + ); + + signature = response.signature; + } catch (error) { + await trackError(error); + logger.error({ error }, 'Error submitting request'); + } + + /** + * Finally, update context + */ + const updatedContext: SendContext = { + ...context, + stage: signature ? 'transaction-success' : 'transaction-failure', + feePaidInSol: feeEstimatedInSol, + transaction: { + result: signature ? 'success' : 'failure', + signature, + }, + }; + + // Finally, show the transaction-complete or transaction-failed stage + await updateInterface(id, , updatedContext); + + // Clean up the interface name to id map + await state.deleteKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`); +} + +export const eventHandlers = { + [TransactionConfirmationNames.BackButton]: onBackButtonClick, + [TransactionConfirmationNames.CancelButton]: onCancelButtonClick, + [TransactionConfirmationNames.ConfirmButton]: onConfirmButtonClick, +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/TransactionFailure/TransactionFailure.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionFailure/TransactionFailure.tsx new file mode 100644 index 00000000..e4f3c73c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionFailure/TransactionFailure.tsx @@ -0,0 +1,38 @@ +import { Box, Container } from '@metamask/snaps-sdk/jsx'; + +import ErrorIcon from '../../../../../images/error.svg'; +import { ActionHeader } from '../../../../core/components/ActionHeader/ActionHeader'; +import { i18n } from '../../../../core/utils/i18n'; +import { TransactionDetails } from '../../components/TransactionDetails/TransactionDetails'; +import { getSelectedTokenMetadata, getTokenAmount } from '../../selectors'; +import type { SendContext } from '../../types'; + +export type TransactionFailureProps = { + context: SendContext; +}; + +export const TransactionFailure = ({ context }: TransactionFailureProps) => { + const { preferences } = context; + + const translate = i18n(preferences.locale); + + const { tokenSymbol } = getSelectedTokenMetadata(context); + const tokenAmount = getTokenAmount(context); + + return ( + + + {null} + + + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/features/send/views/TransactionSuccess/TransactionSuccess.tsx b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionSuccess/TransactionSuccess.tsx new file mode 100644 index 00000000..db011b65 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/features/send/views/TransactionSuccess/TransactionSuccess.tsx @@ -0,0 +1,49 @@ +import { Box, Container, Link } from '@metamask/snaps-sdk/jsx'; + +import CheckIcon from '../../../../../images/check.svg'; +import { ActionHeader } from '../../../../core/components/ActionHeader/ActionHeader'; +import { formatCryptoBalance } from '../../../../core/utils/formatCryptoBalance'; +import { getSolanaExplorerUrl } from '../../../../core/utils/getSolanaExplorerUrl'; +import { i18n } from '../../../../core/utils/i18n'; +import { TransactionDetails } from '../../components/TransactionDetails/TransactionDetails'; +import { getSelectedTokenMetadata, getTokenAmount } from '../../selectors'; +import type { SendContext } from '../../types'; + +export type TransactionSuccessProps = { + context: SendContext; +}; + +export const TransactionSuccess = ({ context }: TransactionSuccessProps) => { + const { preferences, transaction, scope } = context; + + const translate = i18n(preferences.locale); + + const { tokenSymbol } = getSelectedTokenMetadata(context); + const tokenAmount = getTokenAmount(context); + + return ( + + + {null} + + + {transaction?.signature ? ( + + + {translate('send.confirmation.viewTransaction')} + + + ) : null} + + + ); +}; diff --git a/merged-packages/solana-wallet-snap/src/index.test.ts b/merged-packages/solana-wallet-snap/src/index.test.ts new file mode 100644 index 00000000..903d8268 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/index.test.ts @@ -0,0 +1,99 @@ +import { expect } from '@jest/globals'; +import { installSnap } from '@metamask/snaps-jest'; + +import { onCronjob } from '.'; +import { handlers } from './core/handlers/onCronjob'; +import { ScheduleBackgroundEventMethod } from './core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; + +// Avoid loading the ESM-only `@noble/ed25519` package and patching +// `globalThis.crypto.subtle` from this test file. Tests here don't exercise +// crypto, and a global patch would leak into other test suites under +// `--runInBand` because `globalThis` is shared across files. +jest.mock('./polyfills', () => ({ + installPolyfills: jest.fn(), +})); + +jest.mock('./snapContext', () => ({ + keyring: { + listAccounts: jest.fn(), + createAccount: jest.fn(), + }, + state: { + getKey: jest.fn().mockResolvedValue(Date.now()), + setKey: jest.fn(), + setKeyWith: jest.fn(), + }, +})); + +describe('onRpcRequest', () => { + it('throws an error if the requested method does not exist', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'foo', + }); + + expect(response).toRespondWithError({ + code: 4100, + message: 'Permission denied', + stack: expect.any(String), + }); + }); +}); + +describe('onKeyringRequest', () => { + it('throws an error if the requested method does not exist', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'wallet_invokeSnap', + params: { + snapId: 'npm:@metamask/solana-wallet-snap', + request: { + method: 'foo', + }, + }, + }); + + expect(response).toRespondWithError({ + code: 4100, + message: 'Permission denied', + stack: expect.any(String), + }); + }); +}); + +describe('onCronjob', () => { + it('throws an error if the requested method is invalid', async () => { + await expect( + onCronjob({ + request: { + id: '1', + jsonrpc: '2.0', + method: 'foo', + }, + }), + ).rejects.toThrow(/Expected one of/u); + }); + + it('calls the correct handler', async () => { + const handler = jest.fn(); + handlers[ScheduleBackgroundEventMethod.RefreshSend] = handler; + + const snap = { + request: jest.fn().mockResolvedValue({ locked: false, active: true }), + }; + + (globalThis as any).snap = snap; + + await onCronjob({ + request: { + id: '1', + jsonrpc: '2.0', + method: ScheduleBackgroundEventMethod.RefreshSend, + }, + }); + + expect(handler).toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/index.ts b/merged-packages/solana-wallet-snap/src/index.ts new file mode 100644 index 00000000..6f4c13de --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/index.ts @@ -0,0 +1,303 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { + Json, + OnActiveHandler, + OnAssetHistoricalPriceHandler, + OnAssetsConversionHandler, + OnAssetsLookupHandler, + OnAssetsMarketDataHandler, + OnClientRequestHandler, + OnCronjobHandler, + OnInactiveHandler, + OnInstallHandler, + OnKeyringRequestHandler, + OnNameLookupHandler, + OnProtocolRequestHandler, + OnStartHandler, + OnUpdateHandler, + OnUserInputHandler, + OnWebSocketEventHandler, +} from '@metamask/snaps-sdk'; +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { assert, enums } from '@metamask/superstruct'; +import BigNumber from 'bignumber.js'; + +import { onAssetHistoricalPrice as onAssetHistoricalPriceHandler } from './core/handlers/onAssetHistoricalPrice/onAssetHistoricalPrice'; +import { onAssetsConversion as onAssetsConversionHandler } from './core/handlers/onAssetsConversion/onAssetsConversion'; +import { onAssetsLookup as onAssetsLookupHandler } from './core/handlers/onAssetsLookup/onAssetsLookup'; +import { onAssetsMarketData as onAssetsMarketDataHandler } from './core/handlers/onAssetsMarketData/onAssetsMarketData'; +import { handlers as onCronjobHandlers } from './core/handlers/onCronjob'; +import { ScheduleBackgroundEventMethod } from './core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; +import { CronjobMethod } from './core/handlers/onCronjob/cronjobs/CronjobMethod'; +import { onNameLookupHandler } from './core/handlers/onNameLookup/onNameLookup'; +import { onProtocolRequest as onProtocolRequestHandler } from './core/handlers/onProtocolRequest/onProtocolRequest'; +import { handlers as onRpcRequestHandlers } from './core/handlers/onRpcRequest'; +import { RpcRequestMethod } from './core/handlers/onRpcRequest/types'; +import { withCatchAndThrowSnapError } from './core/utils/errors'; +import logger, { createPrefixedLogger } from './core/utils/logger'; +import { validateOrigin } from './core/validation/validators'; +import { eventHandlers as confirmSignInEvents } from './features/confirmation/views/ConfirmSignIn/events'; +import { eventHandlers as confirmSignMessageEvents } from './features/confirmation/views/ConfirmSignMessage/events'; +import { eventHandlers as confirmSignAndSendTransactionEvents } from './features/confirmation/views/ConfirmTransactionRequest/events'; +import { eventHandlers as sendFormEvents } from './features/send/views/SendForm/events'; +import { eventHandlers as transactionConfirmationEvents } from './features/send/views/TransactionConfirmation/events'; +import { installPolyfills } from './polyfills'; +import snapContext, { + clientRequestHandler, + eventEmitter, + keyring, +} from './snapContext'; + +installPolyfills(); + +// Lowest precision we ever go for: MicroLamports represented in Sol amount +BigNumber.config({ EXPONENTIAL_AT: 16 }); + +/** + * Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`. + * + * @param args - The request handler args as object. + * @param args.origin - The origin of the request, e.g., the website that + * invoked the snap. + * @param args.request - A validated JSON-RPC request object. + * @returns A promise that resolves to the result of the RPC request. + * @throws If the request method is not valid for this snap. + */ +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}) => { + logger.log('[🔄 onRpcRequest]', request.method, request); + + const { method } = request; + + validateOrigin(origin, method); + + const handler = onRpcRequestHandlers[method as RpcRequestMethod]; + + if (!handler) { + throw new MethodNotFoundError( + `RpcRequest method ${method} not found. Available methods: ${Object.values( + RpcRequestMethod, + ).toString()}`, + ) as unknown as Error; + } + + const result = await withCatchAndThrowSnapError(async () => + handler({ origin, request }), + ); + + return result ?? null; +}; + +/** + * Handle incoming keyring requests. + * + * @param args - The request handler args as object. + * @param args.origin - The origin of the request, e.g., the website that + * invoked the snap. + * @param args.request - A validated keyring request object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + */ +export const onKeyringRequest: OnKeyringRequestHandler = async ({ + origin, + request, +}): Promise => { + logger.log('[🔑 onKeyringRequest]', request.method, request); + + validateOrigin(origin, request.method); + + // This is a temporal fix to prevent the swap/bridge functionality breaking + // TODO: Remove this once changes in bridge-status-controller are in place + if ( + request.method === KeyringRpcMethod.SubmitRequest && + request.params && + !('origin' in request.params) + ) { + (request.params as Record).origin = 'https://metamask.io'; + } + + const result = await withCatchAndThrowSnapError(async () => + handleKeyringRequest(keyring, request), + ); + + return result ?? null; +}; + +/** + * Handle user events requests. + * + * @param args - The request handler args as object. + * @param args.id - The interface id associated with the event. + * @param args.event - The event object. + * @param args.context - The context object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + */ +export const onUserInput: OnUserInputHandler = async ({ + id, + event, + context, +}) => { + logger.log('[👇 onUserInput]', id, event); + + // Using the name of the component, route it to the correct handler + if (!event.name) { + return; + } + + const uiEventHandlers: Record Promise> = { + ...sendFormEvents, + ...transactionConfirmationEvents, + ...confirmSignAndSendTransactionEvents, + ...confirmSignMessageEvents, + ...confirmSignInEvents, + }; + + const handler = uiEventHandlers[event.name]; + + if (!handler) { + return; + } + + await withCatchAndThrowSnapError(async () => + handler({ id, event, context, snapContext }), + ); +}; + +/** + * Handle incoming cronjob requests. + * + * @param args - The request handler args as object. + * @param args.request - A validated cronjob request object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + * @see https://docs.metamask.io/snaps/reference/entry-points/#oncronjob + */ +export const onCronjob: OnCronjobHandler = async ({ request }) => { + const _logger = createPrefixedLogger(logger, '[⏱️ onCronjob]'); + + _logger.log(request.method, request); + + const { method } = request; + assert( + method, + enums([ + ...Object.values(CronjobMethod), + ...Object.values(ScheduleBackgroundEventMethod), + ]), + ); + + const result = await withCatchAndThrowSnapError(async () => { + _logger.log('Running cronjob', { method }); + + const handler = + onCronjobHandlers[ + method as CronjobMethod | ScheduleBackgroundEventMethod + ]; + + if (!handler) { + throw new MethodNotFoundError( + `Cronjob / ScheduleBackgroundEvent method ${method} not found. Available methods: ${Object.values( + [ + ...Object.values(CronjobMethod), + ...Object.values(ScheduleBackgroundEventMethod), + ], + ).toString()}`, + ) as unknown as Error; + } + return handler({ request }); + }); + + return result ?? null; +}; + +export const onAssetsLookup: OnAssetsLookupHandler = async (params) => { + const result = await withCatchAndThrowSnapError(async () => + onAssetsLookupHandler(params), + ); + return result ?? null; +}; + +export const onAssetsConversion: OnAssetsConversionHandler = async (params) => { + const result = await withCatchAndThrowSnapError(async () => + onAssetsConversionHandler(params), + ); + return result ?? null; +}; + +export const onProtocolRequest: OnProtocolRequestHandler = async (params) => { + const result = await withCatchAndThrowSnapError(async () => + onProtocolRequestHandler(params), + ); + return result ?? null; +}; + +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( + params, +) => { + const result = await withCatchAndThrowSnapError(async () => + onAssetHistoricalPriceHandler(params), + ); + return result ?? null; +}; + +export const onClientRequest: OnClientRequestHandler = async ({ request }) => { + const result = await withCatchAndThrowSnapError(async () => + clientRequestHandler.handle(request), + ); + return result ?? null; +}; + +export const onWebSocketEvent: OnWebSocketEventHandler = async ({ event }) => + withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onWebSocketEvent', event); + }); + +/* + * Lifecycle handlers + */ + +export const onStart: OnStartHandler = async () => + withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onStart'); + }); + +export const onUpdate: OnUpdateHandler = async () => + withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onUpdate'); + }); + +export const onInstall: OnInstallHandler = async () => + withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onInstall'); + }); + +export const onActive: OnActiveHandler = async () => { + return withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onActive'); + }); +}; + +export const onInactive: OnInactiveHandler = async () => { + return withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onInactive'); + }); +}; + +export const onNameLookup: OnNameLookupHandler = async (request) => { + const result = await withCatchAndThrowSnapError(async () => + onNameLookupHandler(request), + ); + return result ?? null; +}; + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async (params) => { + const result = await withCatchAndThrowSnapError(async () => + onAssetsMarketDataHandler(params), + ); + return result ?? null; +}; diff --git a/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.test.ts b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.test.ts new file mode 100644 index 00000000..3065c69a --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.test.ts @@ -0,0 +1,118 @@ +import { mockLogger } from '../../core/services/mocks/logger'; +import { EventEmitter } from './EventEmitter'; + +describe('EventEmitter', () => { + let eventEmitter: EventEmitter; + + beforeEach(() => { + eventEmitter = new EventEmitter(mockLogger); + }); + + describe('on', () => { + it('adds a listener for an event', () => { + const listener = jest.fn(); + + eventEmitter.on('someEvent', listener); + + expect(eventEmitter.listenerCount('someEvent')).toBe(1); + }); + + it('adds multiple listeners for an event', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someEvent', listener2); + + expect(eventEmitter.listenerCount('someEvent')).toBe(2); + }); + }); + + describe('off', () => { + it('removes a listener for an event', () => { + const listener = jest.fn(); + eventEmitter.on('someEvent', listener); + + eventEmitter.off('someEvent', listener); + + expect(eventEmitter.listenerCount('someEvent')).toBe(0); + }); + + it('removes multiple listeners for an event', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someEvent', listener2); + + eventEmitter.off('someEvent', listener1); + + expect(eventEmitter.listenerCount('someEvent')).toBe(1); + }); + }); + + describe('emit', () => { + it('calls all listeners for an event', async () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const listener3 = jest.fn(); + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someEvent', listener2); + eventEmitter.on('someEvent', listener3); + + await eventEmitter.emitSync('someEvent'); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + expect(listener3).toHaveBeenCalled(); + }); + + it('does not call listeners for a different event', async () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someOtherEvent', listener2); + + await eventEmitter.emitSync('someEvent'); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); + }); + }); + + describe('removeAllListeners', () => { + it('removes all listeners for an event', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someEvent', listener2); + + eventEmitter.removeAllListeners('someEvent'); + + expect(eventEmitter.listenerCount('someEvent')).toBe(0); + }); + + it('removes all listeners for all events', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + eventEmitter.on('someEvent', listener1); + eventEmitter.on('someOtherEvent', listener2); + + eventEmitter.removeAllListeners(); + + expect(eventEmitter.listenerCount('someEvent')).toBe(0); + expect(eventEmitter.listenerCount('someOtherEvent')).toBe(0); + }); + }); + + describe('eventNames', () => { + it('returns the names of all events', () => { + eventEmitter.on('someEvent', jest.fn()); + eventEmitter.on('someOtherEvent', jest.fn()); + + expect(eventEmitter.eventNames()).toStrictEqual([ + 'someEvent', + 'someOtherEvent', + ]); + }); + }); +}); diff --git a/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.ts b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.ts new file mode 100644 index 00000000..88656c0d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/EventEmitter.ts @@ -0,0 +1,118 @@ +import { createPrefixedLogger } from '../../core/utils/logger'; +import type { ILogger } from '../../core/utils/logger'; + +type Listener = (data?: any) => Promise; + +/** + * The EventEmitter class is a simple event emitter that allows to register listeners / callbacks for events and emit events. + * + * @example + * ```ts + * const eventEmitter = new EventEmitter(); + * eventEmitter.on('someEvent', () => { + * someFunction(); + * }); + * eventEmitter.emit('someEvent'); + * // 'someFunction' will be called + * ``` + */ +export class EventEmitter { + readonly #logger: ILogger; + + readonly #listeners: Map> = new Map(); + + constructor(logger: ILogger) { + this.#logger = createPrefixedLogger(logger, '[⚡ EventEmitter]'); + } + + /** + * Registers a listener for an event. + * + * @param event - The event to listen to. + * @param listener - The listener to call when the event is emitted. + */ + on(event: string, listener: Listener) { + this.#logger.info(`Adding listener for event ${event}`); + + if (!this.#listeners.has(event)) { + this.#listeners.set(event, new Set()); + } + + this.#listeners.get(event)?.add(listener); + } + + /** + * Removes a listener for an event. + * + * @param event - The event to remove the listener for. + * @param listener - The listener to remove. + */ + off(event: string, listener: Listener) { + this.#logger.info(`Removing listener for event ${event}`); + + const listeners = this.#listeners.get(event); + if (listeners) { + listeners.delete(listener); + + // Clean up empty sets + if (listeners.size === 0) { + this.#listeners.delete(event); + } + } + } + + /** + * Emits an event synchronously, and waits for all listeners to complete. + * The event is emitted to all listeners, and the function returns when all listeners have completed. + * Because of how the snaps platform works, we MUST await for all listeners to complete, otherwise the snap execution will stop. + * + * @param event - The event to emit. + * @param data - The data to pass to the listeners. + */ + async emitSync(event: string, data?: any): Promise { + this.#logger.info(`Emitting event ${event}`); + const listeners = this.#listeners.get(event); + + if (listeners) { + const promises = Array.from(listeners).map( + async (listener) => await listener(data), + ); + await Promise.allSettled(promises); + } + } + + /** + * Removes all listeners for an event. + * + * @param event - The event to remove the listeners for. If not provided, all listeners will be removed. + */ + removeAllListeners(event?: string) { + if (event) { + this.#logger.info(`Removing all listeners for event ${event}`); + this.#listeners.delete(event); + } else { + this.#logger.info('Removing all listeners for all events'); + this.#listeners.clear(); + } + } + + /** + * Returns the number of listeners for an event. + * + * @param event - The event to get the listener count for. + * @returns The number of listeners for the event. + */ + listenerCount(event: string): number { + const listeners = this.#listeners.get(event); + return listeners ? listeners.size : 0; + } + + /** + * Returns the names of all events that have listeners. + * + * @returns The names of all events that have listeners. + */ + eventNames(): string[] { + return Array.from(this.#listeners.keys()); + } +} diff --git a/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/index.ts b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/index.ts new file mode 100644 index 00000000..1a0d4570 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/infrastructure/event-emitter/index.ts @@ -0,0 +1 @@ +export * from './EventEmitter'; diff --git a/merged-packages/solana-wallet-snap/src/infrastructure/index.ts b/merged-packages/solana-wallet-snap/src/infrastructure/index.ts new file mode 100644 index 00000000..decf40ba --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './event-emitter'; diff --git a/merged-packages/solana-wallet-snap/src/permissions.ts b/merged-packages/solana-wallet-snap/src/permissions.ts new file mode 100644 index 00000000..b2cde865 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/permissions.ts @@ -0,0 +1,88 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { KeyringSnapRpcMethod } from '@metamask/keyring-api/v2'; + +import { ClientRequestMethod } from './core/handlers/onClientRequest'; +import { + RpcRequestMethod, + TestDappRpcRequestMethod, +} from './core/handlers/onRpcRequest/types'; +import { ConfigProvider } from './core/services/config/ConfigProvider'; + +const prodOrigins = ['https://portfolio.metamask.io']; + +const config = new ConfigProvider().get(); +const isDev = ['local', 'test'].includes(config.environment); + +const allowedOrigins = isDev ? ['http://localhost:3000'] : prodOrigins; + +const dappPermissions = isDev + ? new Set([ + // Keyring v2 methods + KeyringSnapRpcMethod.GetAccounts, + KeyringSnapRpcMethod.GetAccount, + KeyringSnapRpcMethod.DeleteAccount, + KeyringSnapRpcMethod.GetAccountBalances, + KeyringSnapRpcMethod.SubmitRequest, + KeyringSnapRpcMethod.GetAccountTransactions, + KeyringSnapRpcMethod.GetAccountAssets, + KeyringSnapRpcMethod.SetSelectedAccounts, + // Keyring v1 methods kept for backwards compatibility — callers using + // old method names are still accepted by the permission layer. + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + // RPC methods + RpcRequestMethod.StartSendTransactionFlow, + RpcRequestMethod.GetFeeForTransaction, + // Methods specific to the test dapp + TestDappRpcRequestMethod.ListWebSockets, + TestDappRpcRequestMethod.ListSubscriptions, + TestDappRpcRequestMethod.TestOnStart, + TestDappRpcRequestMethod.TestOnInstall, + TestDappRpcRequestMethod.TestOnUpdate, + TestDappRpcRequestMethod.SynchronizeAccounts, + TestDappRpcRequestMethod.SetAccountSelected, + TestDappRpcRequestMethod.ConfirmSend, + TestDappRpcRequestMethod.SignRewardsMessage, + ]) + : new Set([]); + +const metamaskPermissions = new Set([ + // Keyring v2 methods + KeyringSnapRpcMethod.GetAccounts, + KeyringSnapRpcMethod.GetAccount, + KeyringSnapRpcMethod.CreateAccounts, + KeyringSnapRpcMethod.DeleteAccount, + KeyringSnapRpcMethod.GetAccountBalances, + KeyringSnapRpcMethod.SubmitRequest, + KeyringSnapRpcMethod.GetAccountTransactions, + KeyringSnapRpcMethod.GetAccountAssets, + KeyringSnapRpcMethod.ResolveAccountAddress, + KeyringSnapRpcMethod.SetSelectedAccounts, + KeyringSnapRpcMethod.ExportAccount, + // Keyring v1 methods kept for backwards compatibility — callers using + // old method names are still accepted by the permission layer. + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + // RPC methods + RpcRequestMethod.StartSendTransactionFlow, + RpcRequestMethod.GetFeeForTransaction, + // Client methods + ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, + ClientRequestMethod.SignProofOfOwnership, +]); + +const metamask = 'metamask'; + +export const originPermissions = new Map>([]); + +for (const origin of allowedOrigins) { + originPermissions.set(origin, dappPermissions); +} +originPermissions.set(metamask, metamaskPermissions); diff --git a/merged-packages/solana-wallet-snap/src/polyfills/dom-exception/index.ts b/merged-packages/solana-wallet-snap/src/polyfills/dom-exception/index.ts new file mode 100644 index 00000000..7bf7fbdf --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/dom-exception/index.ts @@ -0,0 +1,11 @@ +/** + * Adds support for the DOMException object. + */ +export function install() { + Object.defineProperty(globalThis, 'DOMException', { + value: Error, + writable: true, + configurable: true, + enumerable: true, + }); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/ed25519/index.ts b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/index.ts new file mode 100644 index 00000000..74520c5d --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/index.ts @@ -0,0 +1,117 @@ +import { + exportKey as exportKeyPolyfill, + generateKey as generateKeyPolyfill, + importKey as importKeyPolyfill, + sign as signPolyfill, + verify as verifyPolyfill, +} from './polyfill'; +import { isEd25519Algorithm } from './utils/is-ed25519-algorithm'; + +/** + * Adds Ed25519 support to Web Crypto API's native methods. + * + * Based on the following libraries: + * https://github.com/solana-labs/solana-web3.js/tree/master/packages/webcrypto-ed25519-polyfill + * https://github.com/yoursunny/webcrypto-ed25519. + * + */ +export function install() { + const { subtle } = globalThis.crypto; + + Object.defineProperty(globalThis, 'isSecureContext', { + value: true, + writable: true, + configurable: true, + }); + + /** + * Override `SubtleCrypto#generateKey` + */ + Object.defineProperty(subtle, 'generateKey', { + value: async (...args: Parameters) => { + const algorithm = args[0]; + + if (!isEd25519Algorithm(algorithm)) { + return await globalThis.crypto.subtle.generateKey(...args); + } + + return await generateKeyPolyfill(...args); + }, + writable: true, + configurable: true, + }); + + /** + * Override `SubtleCrypto#exportKey` + */ + Object.defineProperty(subtle, 'exportKey', { + value: async (...args: Parameters) => { + const key = args[1]; + + if (!isEd25519Algorithm(key.algorithm)) { + return await globalThis.crypto.subtle.exportKey(...args); + } + + return await exportKeyPolyfill(...args); + }, + writable: true, + configurable: true, + }); + + /** + * Override `SubtleCrypto#sign` + */ + Object.defineProperty(subtle, 'sign', { + value: async (...args: Parameters) => { + const [algorithm, key] = args; + + if ( + !isEd25519Algorithm(algorithm) || + !isEd25519Algorithm(key.algorithm) + ) { + return await globalThis.crypto.subtle.sign(...args); + } + + return await signPolyfill(...args); + }, + writable: true, + configurable: true, + }); + + /** + * Override `SubtleCrypto#verify` + */ + Object.defineProperty(subtle, 'verify', { + value: async (...args: Parameters) => { + const [algorithm, key] = args; + + if ( + !isEd25519Algorithm(algorithm) || + !isEd25519Algorithm(key.algorithm) + ) { + return await globalThis.crypto.subtle.verify(...args); + } + + return await verifyPolyfill(...args); + }, + writable: true, + configurable: true, + }); + + /** + * Override `SubtleCrypto#importKey` + */ + Object.defineProperty(subtle, 'importKey', { + value: async (...args: Parameters) => { + const algorithm = args[2]; + + if (!isEd25519Algorithm(algorithm)) { + return await globalThis.crypto.subtle.importKey(...args); + } + + return await importKeyPolyfill(...args); + }, + writable: true, + configurable: true, + }); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/ed25519/polyfill.ts b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/polyfill.ts new file mode 100644 index 00000000..79cc0727 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/polyfill.ts @@ -0,0 +1,358 @@ +/* eslint-disable no-restricted-globals */ +import * as ed from '@noble/ed25519'; + +import { bufferSourceAsUint8Array } from './utils/buffer-source-as-uint8-array'; +import { uint8ArrayAsBufferSource } from './utils/uint8-array-as-buffer-source'; + +const slot = '8d9df0f7-1363-4d2c-8152-ce4ed78f27d8'; + +type Ed25519CryptoKey = { + [slot]: Uint8Array; +} & CryptoKey; + +const ED25519_PKCS8_HEADER = [ + 0x30, + 0x2e, // SEQUENCE + length + 0x02, + 0x01, + 0x00, // INTEGER + length + version + 0x30, + 0x05, + 0x06, + 0x03, + 0x2b, + 0x65, + 0x70, // Algorithm identifier + 0x04, + 0x22, + 0x04, + 0x20, // OCTET STRING wrappers +]; + +/** + * Convert a Uint8Array to a base64 string with URL-safe characters. + * + * @param input - The Uint8Array to convert. + * @returns The base64 string with URL-safe characters. + */ +export function toBase64(input: Uint8Array) { + return Buffer.from(input) + .toString('base64') + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]/gu, ''); +} + +/** + * Convert a base64 string with URL-safe characters to a Uint8Array. + * + * @param b64u - The base64 string with URL-safe characters. + * @returns The Uint8Array. + */ +export function toBuffer(b64u: string) { + const a = b64u.replace(/-/gu, '+').replace(/_/gu, '/'); + const b = new Uint8Array(Buffer.from(a, 'base64')); + + return b; +} + +/** + * Export a key. + * + * @param format - The format of the key data. + * @param key - The key to export. + * @returns The exported key data. + */ +export async function exportKey(format: KeyFormat | string, key: CryptoKey) { + if (!key.extractable) { + throw new DOMException('key is not extractable', 'InvalidAccessException'); + } + + const raw = (key as Ed25519CryptoKey)[slot]; + + switch (format) { + case 'raw': { + if (key.type !== 'public') { + throw new DOMException( + 'Unable to export a raw Ed25519 private key', + 'InvalidAccessError', + ); + } + return raw.buffer; + } + case 'pkcs8': { + if (key.type !== 'private') { + throw new DOMException( + 'Unable to export a pkcs8 Ed25519 public key', + 'InvalidAccessError', + ); + } + return new Uint8Array([...ED25519_PKCS8_HEADER, ...raw]).buffer; + } + case 'jwk': { + const base = { + crv: 'Ed25519', + ext: key.extractable, + + key_ops: key.usages, + kty: 'OKP', + }; + + if (key.type === 'private') { + const publicKey = await ed.getPublicKeyAsync(raw); + return Object.freeze({ + ...base, + d: toBase64(raw), + x: toBase64(publicKey), + }); + } + + return Object.freeze({ + ...base, + x: toBase64(raw), + }); + } + case 'spki': { + if (key.type !== 'public') { + throw new DOMException( + 'Only public keys can be exported as SPKI', + 'InvalidAccessError', + ); + } + const algorithmIdentifier = new Uint8Array([ + 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, + ]); + const bitString = new Uint8Array([0x03, raw.length + 1, 0x00, ...raw]); + const sequence = new Uint8Array([ + 0x30, + algorithmIdentifier.length + bitString.length, + ...algorithmIdentifier, + ...bitString, + ]); + return sequence.buffer; + } + default: + throw new Error(`Unsupported export format: ${format}`); + } +} + +/** + * Generate a key pair. + * + * @param _algorithm - The algorithm to use. + * @param extractable - Whether the key is extractable. + * @param keyUsages - The key usages. + * @returns The key pair. + */ +export async function generateKey( + _algorithm: AlgorithmIdentifier | KeyAlgorithm, + extractable: boolean, + keyUsages: KeyUsage[], +): Promise { + const privateKey = ed.utils.randomPrivateKey(); + const publicKey = await ed.getPublicKeyAsync(privateKey); + const usages = Array.from(keyUsages); + + const privateKeyObject: Ed25519CryptoKey = { + algorithm: { name: 'Ed25519' }, + extractable, + type: 'private', + usages, + [slot]: privateKey, + }; + + const publicKeyObject: Ed25519CryptoKey = { + algorithm: { name: 'Ed25519' }, + extractable: true, + type: 'public', + usages, + [slot]: publicKey, + }; + + return { privateKey: privateKeyObject, publicKey: publicKeyObject }; +} + +/** + * Import a key. + * + * @param format - The format of the key data. + * @param keyData - The key data to import. + * @param _algorithm - The algorithm to use. + * @param extractable - Whether the key is extractable. + * @param keyUsages - The key usages. + * @returns The imported key. + */ +export async function importKey( + format: KeyFormat | string, + keyData: BufferSource | JsonWebKey, + _algorithm: AlgorithmIdentifier | KeyAlgorithm, + extractable: boolean, + keyUsages: KeyUsage[], +) { + const usages = Array.from(keyUsages); + + switch (format) { + case 'raw': { + const bytes = bufferSourceAsUint8Array(keyData as BufferSource); + if (bytes.length !== 32) { + throw new DOMException( + 'Ed25519 raw keys must be exactly 32-bytes', + 'DataError', + ); + } + + return { + algorithm: { name: 'Ed25519' }, + extractable, + type: 'public', + usages: usages.filter((usage) => usage === 'verify'), + [slot]: bytes, + } as Ed25519CryptoKey; + } + case 'pkcs8': { + const bytes = bufferSourceAsUint8Array(keyData as BufferSource); + if (bytes.length !== 48) { + // 16-byte header + 32-byte key + throw new DOMException('Invalid PKCS8 key data length', 'DataError'); + } + + const header = bytes.slice(0, 16); + if (!header.every((val, i) => val === ED25519_PKCS8_HEADER[i])) { + throw new DOMException('Invalid PKCS8 header', 'DataError'); + } + + return { + algorithm: { name: 'Ed25519' }, + extractable, + type: 'private', + usages: usages.filter((usage) => usage === 'sign'), + [slot]: bytes.slice(16), + } as Ed25519CryptoKey; + } + case 'jwk': { + const jwk = keyData as JsonWebKey; + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new DOMException('Invalid Ed25519 JWK', 'DataError'); + } + + const type = 'd' in jwk ? 'private' : 'public'; + + if (type === 'public' && !jwk.x) { + throw new DOMException( + 'Ed25519 JWK is missing public key', + 'DataError', + ); + } + if (type === 'private' && (!jwk.d || !jwk.x)) { + throw new DOMException('Ed25519 JWK is missing key data', 'DataError'); + } + + const slotData = + type === 'private' ? (jwk.d as string) : (jwk.x as string); + + return { + algorithm: { name: 'Ed25519' }, + extractable, + type, + usages: usages.filter((usage) => + type === 'private' ? usage === 'sign' : usage === 'verify', + ), + [slot]: toBuffer(slotData), + } as Ed25519CryptoKey; + } + case 'spki': { + const data = bufferSourceAsUint8Array(keyData as BufferSource); + + if (data[0] !== 0x30) { + throw new DOMException('Invalid SPKI format', 'DataError'); + } + + const algorithmStart = 2; + if ( + data[algorithmStart] !== 0x30 || + data[algorithmStart + 2] !== 0x06 || + data[algorithmStart + 3] !== 0x03 || + data[algorithmStart + 4] !== 0x2b || + data[algorithmStart + 5] !== 0x65 || + data[algorithmStart + 6] !== 0x70 + ) { + throw new DOMException('Not an Ed25519 key', 'DataError'); + } + + const keyStart = algorithmStart + 7 + 2; + return { + algorithm: { name: 'Ed25519' }, + extractable: true, + type: 'public', + usages: usages.filter((usage) => usage === 'verify'), + [slot]: data.slice(keyStart), + } as Ed25519CryptoKey; + } + default: + throw new Error(`Unsupported import format: ${format}`); + } +} + +/** + * Sign data using an Ed25519 private key. + * + * @param _algorithm - The algorithm to use. + * @param key - The private key to use. + * @param data - The data to sign. + * @returns The signature. + */ +export async function sign( + _algorithm: AlgorithmIdentifier, + key: CryptoKey, + data: BufferSource, +): Promise { + if (key.type !== 'private') { + throw new DOMException('Key is not private', 'NotAllowedError'); + } + + if (!key.usages.includes('sign')) { + throw new DOMException('Key usage not allowed', 'NotAllowedError'); + } + + const message = bufferSourceAsUint8Array(data); + const privateKey = (key as Ed25519CryptoKey)[slot]; + const signature = await ed.signAsync(message, privateKey); + const signatureAsBufferSource = uint8ArrayAsBufferSource(signature); + + return signatureAsBufferSource; +} + +/** + * Verify a signature using an Ed25519 public key. + * + * @param _algorithm - The algorithm to use. + * @param key - The public key to use. + * @param signature - The signature to verify. + * @param data - The data to verify. + * @returns True if the signature is valid, false otherwise. + */ +export async function verify( + _algorithm: AlgorithmIdentifier, + key: CryptoKey, + signature: BufferSource, + data: BufferSource, +): Promise { + if (key.type !== 'public') { + throw new DOMException('Key is not public', 'NotAllowedError'); + } + + if (!key.usages.includes('verify')) { + throw new DOMException('Key usage not allowed', 'NotAllowedError'); + } + + const signatureAsUint8Array = bufferSourceAsUint8Array(signature); + const message = bufferSourceAsUint8Array(data); + const privateKey = (key as Ed25519CryptoKey)[slot]; + const verified = await ed.verifyAsync( + signatureAsUint8Array, + message, + privateKey, + ); + + return verified; +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/buffer-source-as-uint8-array.ts b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/buffer-source-as-uint8-array.ts new file mode 100644 index 00000000..a80d1697 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/buffer-source-as-uint8-array.ts @@ -0,0 +1,17 @@ +/** + * Convert a BufferSource to a Uint8Array. + * + * @param source - The BufferSource to convert. + * @returns The Uint8Array. + */ +export function bufferSourceAsUint8Array(source: BufferSource): Uint8Array { + if (source instanceof Uint8Array) { + return source; + } + + if (source instanceof ArrayBuffer) { + return new Uint8Array(source); + } + + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/is-ed25519-algorithm.ts b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/is-ed25519-algorithm.ts new file mode 100644 index 00000000..83011f0e --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/is-ed25519-algorithm.ts @@ -0,0 +1,14 @@ +/** + * Check if the algorithm is Ed25519. + * + * @param algorithm - The algorithm to check. + * @returns True if the algorithm is Ed25519, false otherwise. + */ +export function isEd25519Algorithm( + algorithm: AlgorithmIdentifier | KeyAlgorithm, +): boolean { + return ( + algorithm === 'Ed25519' || + (typeof algorithm === 'object' && algorithm.name === 'Ed25519') + ); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/uint8-array-as-buffer-source.ts b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/uint8-array-as-buffer-source.ts new file mode 100644 index 00000000..b80e6c6f --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/ed25519/utils/uint8-array-as-buffer-source.ts @@ -0,0 +1,13 @@ +/** + * Convert a Uint8Array to a BufferSource. + * + * @param array - The Uint8Array to convert. + * @returns The BufferSource. + */ +export function uint8ArrayAsBufferSource(array: Uint8Array): BufferSource { + if (array.byteLength === array.buffer.byteLength) { + return array.buffer; + } + + return array.buffer.slice(array.byteOffset, array.byteLength); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/index.ts b/merged-packages/solana-wallet-snap/src/polyfills/index.ts new file mode 100644 index 00000000..1f6c79ba --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/index.ts @@ -0,0 +1,12 @@ +import { install as installDOMExceptionPolyfill } from './dom-exception'; +import { install as installEd25519Polyfill } from './ed25519'; +import { install as installQueueMicrotaskPolyfill } from './queue-microtask'; + +/** + * Installs all polyfills. + */ +export function installPolyfills() { + installDOMExceptionPolyfill(); + installEd25519Polyfill(); + installQueueMicrotaskPolyfill(); +} diff --git a/merged-packages/solana-wallet-snap/src/polyfills/queue-microtask/index.ts b/merged-packages/solana-wallet-snap/src/polyfills/queue-microtask/index.ts new file mode 100644 index 00000000..c2afba0c --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/polyfills/queue-microtask/index.ts @@ -0,0 +1,14 @@ +const queueMicrotask = async (callback: () => void) => + Promise.resolve().then(callback); + +/** + * Adds support for the window#queueMicrotask() function. + */ +export function install() { + Object.defineProperty(globalThis, 'queueMicrotask', { + value: queueMicrotask, + writable: true, + configurable: true, + enumerable: true, + }); +} diff --git a/merged-packages/solana-wallet-snap/src/snapContext.ts b/merged-packages/solana-wallet-snap/src/snapContext.ts new file mode 100644 index 00000000..58e48856 --- /dev/null +++ b/merged-packages/solana-wallet-snap/src/snapContext.ts @@ -0,0 +1,322 @@ +import type { ICache } from './core/caching/ICache'; +import { InMemoryCache } from './core/caching/InMemoryCache'; +import { StateCache } from './core/caching/StateCache'; +import { NftApiClient } from './core/clients/nft-api/NftApiClient'; +import { PriceApiClient } from './core/clients/price-api/PriceApiClient'; +import { SecurityAlertsApiClient } from './core/clients/security-alerts-api/SecurityAlertsApiClient'; +import { TokenApiClient } from './core/clients/token-api-client/TokenApiClient'; +import { ClientRequestHandler } from './core/handlers'; +import { SolanaKeyring } from './core/handlers/onKeyringRequest/Keyring'; +import type { Serializable } from './core/serialization/types'; +import { + AccountsRepository, + AccountsService, + AccountsSynchronizer, + ApproveTokenService, + AssetsRepository, + AssetsService, + KeyringAccountMonitor, + MonitoredAccountsInitializer, + RecipientClassifier, + SendService, + SendSolBuilder, + SendSplTokenBuilder, + SignatureMonitor, + Signer, + SubscriptionRepository, + SubscriptionService, + TokenHelper, + TransactionMapper, + TransactionsRepository, + TransactionsService, + WebSocketConnectionRepository, + WebSocketConnectionService, +} from './core/services'; +import { AnalyticsService } from './core/services/analytics/AnalyticsService'; +import { ConfigProvider } from './core/services/config'; +import { ConfirmationHandler } from './core/services/confirmation/ConfirmationHandler'; +import { SolanaConnection } from './core/services/connection/SolanaConnection'; +import { NameResolutionService } from './core/services/name-resolution/NameResolutionService'; +import { NftService } from './core/services/nft/NftService'; +import type { IStateManager } from './core/services/state/IStateManager'; +import type { UnencryptedStateValue } from './core/services/state/State'; +import { DEFAULT_UNENCRYPTED_STATE, State } from './core/services/state/State'; +import { TokenPricesService } from './core/services/token-prices/TokenPrices'; +import { TransactionScanService } from './core/services/transaction-scan/TransactionScan'; +import { WalletService } from './core/services/wallet/WalletService'; +import logger, { noOpLogger } from './core/utils/logger'; +import { EventEmitter } from './infrastructure'; + +/** + * Initializes all the services using dependency injection. + */ + +export type SnapExecutionContext = { + configProvider: ConfigProvider; + connection: SolanaConnection; + keyring: SolanaKeyring; + priceApiClient: PriceApiClient; + state: IStateManager; + assetsService: AssetsService; + tokenPricesService: TokenPricesService; + signer: Signer; + transactionsService: TransactionsService; + sendSolBuilder: SendSolBuilder; + sendSplTokenBuilder: SendSplTokenBuilder; + walletService: WalletService; + transactionScanService: TransactionScanService; + analyticsService: AnalyticsService; + confirmationHandler: ConfirmationHandler; + cache: ICache; + nftService: NftService; + clientRequestHandler: ClientRequestHandler; + webSocketConnectionService: WebSocketConnectionService; + subscriptionService: SubscriptionService; + eventEmitter: EventEmitter; + nameResolutionService: NameResolutionService; + accountsService: AccountsService; + accountsSynchronizer: AccountsSynchronizer; + tokenHelper: TokenHelper; +}; + +const configProvider = new ConfigProvider(); + +const eventEmitter = new EventEmitter(logger); + +const state = new State(eventEmitter, { + encrypted: false, + defaultState: DEFAULT_UNENCRYPTED_STATE, +}); + +const stateCache = new StateCache(state, logger); +const inMemoryCache = new InMemoryCache(noOpLogger); + +const analyticsService = new AnalyticsService(logger); + +const connection = new SolanaConnection(configProvider, inMemoryCache); + +const webSocketConnectionRepository = new WebSocketConnectionRepository( + configProvider, +); + +const webSocketConnectionService = new WebSocketConnectionService( + webSocketConnectionRepository, + analyticsService, + configProvider, + state, + eventEmitter, + logger, +); + +const subscriptionRepository = new SubscriptionRepository(state); + +const subscriptionService = new SubscriptionService( + webSocketConnectionService, + subscriptionRepository, + eventEmitter, + logger, +); + +const tokenHelper = new TokenHelper(connection); + +const signer = new Signer(connection, logger); + +const sendSolBuilder = new SendSolBuilder(connection, logger); + +const recipientClassifier = new RecipientClassifier(connection, logger); + +const sendSplTokenBuilder = new SendSplTokenBuilder( + tokenHelper, + recipientClassifier, + connection, + logger, +); + +const priceApiClient = new PriceApiClient(configProvider, inMemoryCache); +const tokenApiClient = new TokenApiClient(configProvider); +const nftApiClient = new NftApiClient(configProvider, inMemoryCache); + +const tokenPricesService = new TokenPricesService({ + configProvider, + priceApiClient, + logger, +}); +const nameResolutionService = new NameResolutionService(connection, logger); + +const assetsRepository = new AssetsRepository(state); +const assetsService = new AssetsService({ + connection, + logger, + configProvider, + assetsRepository, + tokenApiClient, + cache: inMemoryCache, + tokenPricesService, + nftApiClient, +}); + +const accountsRepository = new AccountsRepository(state); +const accountsService = new AccountsService(accountsRepository); + +const transactionsRepository = new TransactionsRepository(state); +const transactionMapper = new TransactionMapper( + tokenHelper, + assetsService, + logger, +); +const transactionsService = new TransactionsService( + transactionsRepository, + transactionMapper, + accountsService, + assetsService, + connection, + logger, +); + +const accountsSynchronizer = new AccountsSynchronizer( + accountsService, + assetsService, + transactionsService, + logger, +); + +const transactionScanService = new TransactionScanService( + new SecurityAlertsApiClient(configProvider), + analyticsService, + logger, +); + +const confirmationHandler = new ConfirmationHandler(); + +const signatureMonitor = new SignatureMonitor( + subscriptionService, + accountsService, + transactionsService, + analyticsService, + connection, + configProvider, + logger, +); + +const keyringAccountMonitor = new KeyringAccountMonitor( + subscriptionService, + accountsService, + assetsService, + transactionsService, + accountsSynchronizer, + tokenHelper, + configProvider, + logger, +); + +const monitoredAccountsInitializer = new MonitoredAccountsInitializer( + accountsService, + keyringAccountMonitor, + eventEmitter, + logger, +); + +const walletService = new WalletService( + connection, + signer, + signatureMonitor, + analyticsService, + logger, +); + +const keyring = new SolanaKeyring({ + state, + transactionsService, + logger, + assetsService, + walletService, + confirmationHandler, + keyringAccountMonitor, +}); + +const nftService = new NftService(connection, logger); + +const sendService = new SendService( + connection, + keyring, + logger, + inMemoryCache, + recipientClassifier, + sendSolBuilder, + sendSplTokenBuilder, + assetsService, +); + +const approveTokenService = new ApproveTokenService( + connection, + tokenHelper, + logger, +); + +const clientRequestHandler = new ClientRequestHandler( + accountsService, + walletService, + logger, + sendService, + approveTokenService, +); + +const snapContext: SnapExecutionContext = { + configProvider, + connection, + keyring, + priceApiClient, + state, + cache: stateCache, + /* Services */ + assetsService, + tokenPricesService, + signer, + transactionsService, + sendSolBuilder, + sendSplTokenBuilder, + walletService, + transactionScanService, + analyticsService, + confirmationHandler, + nftService, + clientRequestHandler, + webSocketConnectionService, + subscriptionService, + eventEmitter, + nameResolutionService, + accountsService, + accountsSynchronizer, + tokenHelper, +}; + +export { + accountsService, + accountsSynchronizer, + analyticsService, + assetsService, + clientRequestHandler, + configProvider, + confirmationHandler, + connection, + eventEmitter, + keyring, + nameResolutionService, + nftService, + priceApiClient, + sendSolBuilder, + sendSplTokenBuilder, + signer, + state, + subscriptionRepository, + subscriptionService, + tokenApiClient, + tokenHelper, + tokenPricesService, + transactionScanService, + transactionsService, + walletService, + webSocketConnectionService, +}; + +export default snapContext; diff --git a/merged-packages/solana-wallet-snap/svg-transformer.js b/merged-packages/solana-wallet-snap/svg-transformer.js new file mode 100644 index 00000000..c962068f --- /dev/null +++ b/merged-packages/solana-wallet-snap/svg-transformer.js @@ -0,0 +1,7 @@ +module.exports = { + process(src) { + return { + code: `module.exports = ${JSON.stringify(src)};`, + }; + }, +}; diff --git a/merged-packages/solana-wallet-snap/tsconfig.json b/merged-packages/solana-wallet-snap/tsconfig.json new file mode 100644 index 00000000..0af59b15 --- /dev/null +++ b/merged-packages/solana-wallet-snap/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true + }, + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] +}