diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json new file mode 100644 index 00000000..4e7ac37c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -0,0 +1,3 @@ +{ + "ignores": ["@metamask/auto-changelog", "jest-transform-stub", "ts-jest"] +} diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example new file mode 100644 index 00000000..34dba6a5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -0,0 +1,37 @@ +# Log Level +# Possible Options: error, warn, info, debug, trace, silent +# Default: info +# Required: false +LOG_LEVEL= + +# Default address type +# Possible options: p2pkh, p2sh, p2wpkh, p2wsh, p2tr +# Default: p2wpkh +# Required: false +DEFAULT_ADDRESS_TYPE= + +# Esplora endpoint for Bitcoin network +# Default: https://blockstream.info/api +# Required: false +ESPLORA_BITCOIN= +# Esplora endpoint for Testnet network +# Default: https://blockstream.info/testnet/api +# Required: false +ESPLORA_TESTNET= +# Esplora endpoint for Testnet4 network +# Default: https://mempool.space/testnet4/api/v1 +# Required: false +ESPLORA_TESTNET4= +# Esplora endpoint for Signet network +# Default: https://mutinynet.com/api +# Required: false +ESPLORA_SIGNET= +# Esplora endpoint for local regtest network +# Default: http://localhost:8094/regtest/api +# Required: false +ESPLORA_REGTEST= + +# Price API endpoint +# Default: https://price.api.cx.metamask.io +# Required: false +PRICE_API_URL= diff --git a/merged-packages/bitcoin-wallet-snap/.prettierignore b/merged-packages/bitcoin-wallet-snap/.prettierignore new file mode 100644 index 00000000..a60030e3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.prettierignore @@ -0,0 +1,2 @@ +dist/ +coverage/ diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md new file mode 100644 index 00000000..75c4ec47 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -0,0 +1,701 @@ +# 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] + +## [1.15.0] + +### Fixed + +- Handle non-url origins ([#634](https://github.com/MetaMask/snap-bitcoin-wallet/pull/634)) +- Display known non-URL origins in confirmations without throwing on invalid origin values ([#640](https://github.com/MetaMask/snap-bitcoin-wallet/pull/640)) + +## [1.14.2] + +### Fixed + +- Accept the optional `options` object in `signAndSendTransaction` request params to fix broken BTC bridging ([#636](https://github.com/MetaMask/snap-bitcoin-wallet/pull/636)) + +## [1.14.1] + +### Fixed + +- Throw a `WalletError` when `WebAssembly` is unavailable (e.g. iOS Lockdown Mode) ([#628](https://github.com/MetaMask/snap-bitcoin-wallet/pull/628)) + - The snap was previously crashing with a confusing `"undefined is not an object (evaluating 'WebAssembly.instantiate')"` error. + +## [1.14.0] + +### Added + +- Add `signProofOfOwnership` client request method ([#626](https://github.com/MetaMask/snap-bitcoin-wallet/pull/626)) + - This method silently signs `metamask:proof-of-ownership::
` messages with the account's BIP-322 signer + +## [1.13.0] + +### Changed + +- Cache account response metadata to avoid loading full BDK wallets for faster checks ([#612](https://github.com/MetaMask/snap-bitcoin-wallet/pull/612/changes)) + +## [1.12.0] + +### Added + +- Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) + +### Changed + +- Enable `keyring_createAccounts` ([#611](https://github.com/MetaMask/snap-bitcoin-wallet/pull/611)) + +### Fixed + +- Show the filled PSBT in the `signPsbt` confirmation dialog so the displayed transaction matches the one being signed ([#606](https://github.com/MetaMask/snap-bitcoin-wallet/pull/606)) + +## [1.11.0] + +### Added + +- Add `canBeMalleable` flag to broadcast/send response shapes so consumers can detect when a transaction id may be rewritten by a third party before confirmation (relevant only for legacy P2PKH accounts, currently always `false`) ([#599](https://github.com/MetaMask/snap-bitcoin-wallet/pull/599)) +- Add `insertMany` to repository API ([#609](https://github.com/MetaMask/snap-bitcoin-wallet/pull/609)) +- Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) + +### Changed + +- Show a self-send warning in the send confirmation when the recipient address belongs to the sending account ([#607](https://github.com/MetaMask/snap-bitcoin-wallet/pull/607)) +- Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) +- Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) +- Bump `@metamask/keyring-api` from `^21.3.0` to `^22.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) +- Bump `@metamask/keyring-snap-sdk` from `^7.1.1` to `^8.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) +- Bump `@metamask/snaps-sdk` from `^10.3.0` to `^11.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) + +### Fixed + +- Preserve original error message and class when wrapping unknown errors at the handler boundary ([#600](https://github.com/MetaMask/snap-bitcoin-wallet/pull/600)) +- Display unconfirmed change to the external keychain (e.g. bridge/swap refunds) in the account balance so it no longer drops to zero while a partial-spend tx is pending ([#605](https://github.com/MetaMask/snap-bitcoin-wallet/pull/605)) + +## [1.10.1] + +### Fixed + +- Include BDK cause message in PSBT build error for better diagnostics ([#594](https://github.com/MetaMask/snap-bitcoin-wallet/pull/594)) + +## [1.10.0] + +### Changed + +- Modify balance/transaction events sending ([#577](https://github.com/MetaMask/snap-bitcoin-wallet/pull/577)) +- Bump `@metamask/keyring-api` from `^21.1.0` to `^21.3.0` ([#574](https://github.com/MetaMask/snap-bitcoin-wallet/pull/574)) +- Bump `@metamask/keyring-snap-sdk` from `^7.1.0` to `^7.1.1` ([#574](https://github.com/MetaMask/snap-bitcoin-wallet/pull/574)) + +## [1.9.0] + +### Changed + +- Add change utxo dropped when full swap use case ([#572](https://github.com/MetaMask/snap-bitcoin-wallet/pull/572)) +- Update p2wsh, p2tr and p2sh dust minimum value ([#570](https://github.com/MetaMask/snap-bitcoin-wallet/pull/570)) + +## [1.8.0] + +### Changed + +- feat: Remove `onRPCRequest` ([#568](https://github.com/MetaMask/snap-bitcoin-wallet/pull/568)) + +## [1.7.0] + +### Added + +- Add `signRewardsMessage` method ([#566](https://github.com/MetaMask/snap-bitcoin-wallet/pull/566)) + +## [1.6.0] + +### Changed + +- Add `from` and `to` to confirmation ([#563](https://github.com/MetaMask/snap-bitcoin-wallet/pull/563)) + +## [1.5.0] + +### Added + +- Add perf tracing account creation ([#557](https://github.com/MetaMask/snap-bitcoin-wallet/pull/557)) + +### Changed + +- State `accounts` under encrypted state ([#560](https://github.com/MetaMask/snap-bitcoin-wallet/pull/560)) + +## [1.4.5] + +### Added + +- Add minimal amount validation ([#558](https://github.com/MetaMask/snap-bitcoin-wallet/pull/558)) + +## [1.4.4] + +### Fixed + +- Sync on setSelectedAccounts & fix network name format ([#554](https://github.com/MetaMask/snap-bitcoin-wallet/pull/554)) + +## [1.4.3] + +### Fixed + +- Fix fingerprint format (for BDK) ([#552](https://github.com/MetaMask/snap-bitcoin-wallet/pull/552)) + +## [1.4.2] + +### Changed + +- Bringing back `fullScan` on discovery and making `setSelectedAccount` run `fullScan` in the background ([#548](https://github.com/MetaMask/snap-bitcoin-wallet/pull/548)) + +## [1.4.1] + +### Fixed + +- Add defensive code for fetching prices ([#546](https://github.com/MetaMask/snap-bitcoin-wallet/pull/546)) + +## [1.4.0] + +### Added + +- Add support for `setSelectedAccounts` ([#543](https://github.com/MetaMask/snap-bitcoin-wallet/pull/543)) +- Cache spot prices for consecutive calls ([#544](https://github.com/MetaMask/snap-bitcoin-wallet/pull/544)) + +## [1.3.0] + +### Fixed + +- Fix confirmSend UI modal ([#541](https://github.com/MetaMask/snap-bitcoin-wallet/pull/541)) +- Remove account address snap tracking ([#539](https://github.com/MetaMask/snap-bitcoin-wallet/pull/539)) +- Update locales ([#540](https://github.com/MetaMask/snap-bitcoin-wallet/pull/540)) + +## [1.2.0] + +### Added + +- Limit account creation & discovery to P2WPKH addresses ([#537](https://github.com/MetaMask/snap-bitcoin-wallet/pull/537)) +- Implement confirm send UI ([#536](https://github.com/MetaMask/snap-bitcoin-wallet/pull/536)) +- Add confirmSend for unified send flow ([#533](https://github.com/MetaMask/snap-bitcoin-wallet/pull/533)) + +## [1.1.0] + +### Added + +- onAmountInput and onAddressInput RPC methods ([#532](https://github.com/MetaMask/snap-bitcoin-wallet/pull/532)) + +### Fixed + +- Defensive code around snap_eventTrack ([#531](https://github.com/MetaMask/snap-bitcoin-wallet/pull/531)) +- Error message on synchronization ([#529](https://github.com/MetaMask/snap-bitcoin-wallet/pull/529)) + +## [1.0.0] + +### Added + +- Background events executed only when client is active ([#524](https://github.com/MetaMask/snap-bitcoin-wallet/pull/524)) +- Confirmation displayed on `signMessage` ([#523](https://github.com/MetaMask/snap-bitcoin-wallet/pull/523)) +- OpenRPC for dApp connectivity ([#522](https://github.com/MetaMask/snap-bitcoin-wallet/pull/522)) +- Sign message on `submitRequest` ([#521](https://github.com/MetaMask/snap-bitcoin-wallet/pull/521)) +- UTXO management on `submitRequest` ([#520](https://github.com/MetaMask/snap-bitcoin-wallet/pull/520)) +- Send transfer on `submitRequest` ([#519](https://github.com/MetaMask/snap-bitcoin-wallet/pull/519)) +- PSBT management on `submitRequest` ([#514](https://github.com/MetaMask/snap-bitcoin-wallet/pull/514)) + +### Changed + +- Translations updated in all languages ([#525](https://github.com/MetaMask/snap-bitcoin-wallet/pull/525)) + +### Fixed + +- Translations aligned with locales ([#528](https://github.com/MetaMask/snap-bitcoin-wallet/pull/528)) + +## [0.19.3] + +### Fixed + +- Treat empty strings as unset in config ([#517](https://github.com/MetaMask/snap-bitcoin-wallet/pull/517)) + +## [0.19.2] + +### Fixed + +- Fix issue where DIN endpoints where not used in `v0.19.1` + +## [0.19.1] + +### Changed + +- Use DIN endpoints for Bitcoin and Bitcoin Testnet (no PR, just a change in CI secrets) + +## [0.19.0] + +### Added + +- PSBT transaction fee computation ([#507](https://github.com/MetaMask/snap-bitcoin-wallet/pull/507)) +- Fill, sign and send PSBT functionality ([#504](https://github.com/MetaMask/snap-bitcoin-wallet/pull/504)) +- OpenRPC documentation for onClientRequest ([#506](https://github.com/MetaMask/snap-bitcoin-wallet/pull/506)) +- Event tracking verification for account synchronization ([#503](https://github.com/MetaMask/snap-bitcoin-wallet/pull/503)) + +### Changed + +- Complete documentation refactor ([#505](https://github.com/MetaMask/snap-bitcoin-wallet/pull/505)) + +## [0.18.0] + +### Added + +- Fine-grained error handling with tracking and translations ([#496](https://github.com/MetaMask/snap-bitcoin-wallet/pull/496), [#498](https://github.com/MetaMask/snap-bitcoin-wallet/pull/498)) +- Track transaction events ([#495](https://github.com/MetaMask/snap-bitcoin-wallet/pull/495)) +- Add account options in `KeyringAccount` ([#499](https://github.com/MetaMask/snap-bitcoin-wallet/pull/499)) + +### Fixed + +- Retry mechanism for Esplora indexer ([#500](https://github.com/MetaMask/snap-bitcoin-wallet/pull/500)) + +## [0.17.0] + +### Changed + +- Synchronize by default ([#492](https://github.com/MetaMask/snap-bitcoin-wallet/pull/492)) + +### Fixed + +- Synchronize accounts with no history ([#491](https://github.com/MetaMask/snap-bitcoin-wallet/pull/491)) + +## [0.16.1] + +### Fixed + +- Typo `onAssetsMarketData` ([#489](https://github.com/MetaMask/snap-bitcoin-wallet/pull/489)) + +## [0.16.0] + +### Added + +- Asset market data on new handler ([#486](https://github.com/MetaMask/snap-bitcoin-wallet/pull/486)) +- Send flow error handling ([#482](https://github.com/MetaMask/snap-bitcoin-wallet/pull/482)) +- Translations ([#483](https://github.com/MetaMask/snap-bitcoin-wallet/pull/483)) + +### Changed + +- Use event value on user input instead of fetching the form state ([#485](https://github.com/MetaMask/snap-bitcoin-wallet/pull/485)) + +## [0.15.0] + +### Added + +- Account index auto increment on creation ([#471](https://github.com/MetaMask/snap-bitcoin-wallet/pull/471)) +- Account selector in send flow ([#479](https://github.com/MetaMask/snap-bitcoin-wallet/pull/479)) +- Market data ([#478](https://github.com/MetaMask/snap-bitcoin-wallet/pull/478)) +- Switch currencies in send flow ([#477](https://github.com/MetaMask/snap-bitcoin-wallet/pull/477)) + +### Changed + +- Align Send flow ([#472](https://github.com/MetaMask/snap-bitcoin-wallet/pull/472)) + +### Fixed + +- Entropy source as part of the `KeyringAccount` options ([#473](https://github.com/MetaMask/snap-bitcoin-wallet/pull/473)) +- Delete account keeping residue in state ([#469](https://github.com/MetaMask/snap-bitcoin-wallet/pull/469)) + +## [0.14.1] + +### Fixed + +- Account name passthrough ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) +- Prevent account deletion key residues ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) + +## [0.14.0] + +### Added + +- Discover accounts ([#464](https://github.com/MetaMask/snap-bitcoin-wallet/pull/464), [#460](https://github.com/MetaMask/snap-bitcoin-wallet/pull/460)) +- Use address types from `keyring-api` ([#461](https://github.com/MetaMask/snap-bitcoin-wallet/pull/461)) + +### Changed + +- Development environment cleanup ([#459](https://github.com/MetaMask/snap-bitcoin-wallet/pull/459)) +- Use `setState` and `getState` instead of `manageState` ([#463](https://github.com/MetaMask/snap-bitcoin-wallet/pull/463)) + +## [0.13.0] + +### Added + +- Multi account ([#457](https://github.com/MetaMask/snap-bitcoin-wallet/pull/457)) +- Historical prices ([#456](https://github.com/MetaMask/snap-bitcoin-wallet/pull/456)) + +## [0.12.1] + +### Added + +- Add unconfirmed transactions to wallet history ([#453](https://github.com/MetaMask/snap-bitcoin-wallet/pull/453)) + +### Changed + +- Reduce bundle size ([#454](https://github.com/MetaMask/snap-bitcoin-wallet/pull/454)) + +## [0.12.0] + +### Added + +- Compress preinstalled Snap during build ([#451](https://github.com/MetaMask/snap-bitcoin-wallet/pull/451)) + +### Changed + +- Disable UTXO protection ([#445](https://github.com/MetaMask/snap-bitcoin-wallet/pull/445)) +- Generate PSBT in send flow ([#448](https://github.com/MetaMask/snap-bitcoin-wallet/pull/448)) + +### Removed + +- Remove SimpleHash ([#447](https://github.com/MetaMask/snap-bitcoin-wallet/pull/447)) + +## [0.11.0] + +### Added + +- Support for `correlationId` and `entropySource` ([#443](https://github.com/MetaMask/snap-bitcoin-wallet/pull/443)) + +### Changed + +- Refactor error handling ([#442](https://github.com/MetaMask/snap-bitcoin-wallet/pull/442)) +- Refactor locale ([#433](https://github.com/MetaMask/snap-bitcoin-wallet/pull/433)) +- Refactor logger ([#437](https://github.com/MetaMask/snap-bitcoin-wallet/pull/437)) +- Reduce bundle size ([#439](https://github.com/MetaMask/snap-bitcoin-wallet/pull/439)) + +### Fixed + +- Discard own outputs from send transactions ([#441](https://github.com/MetaMask/snap-bitcoin-wallet/pull/441)) + +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#403](https://github.com/MetaMask/snap-bitcoin-wallet/pull/403)) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + +## [0.9.0] + +### Added + +- Add localized messages ([#348](https://github.com/MetaMask/snap-bitcoin-wallet/pull/348)) +- Add basic Sats protection support ([#337](https://github.com/MetaMask/snap-bitcoin-wallet/pull/337)), ([#284](https://github.com/MetaMask/snap-bitcoin-wallet/pull/284)), ([#349](https://github.com/MetaMask/snap-bitcoin-wallet/pull/349)) + - Using SimpleHash service. + +### Changed + +- **BREAKING:** Provide scopes field to `KeyringAccount` during account creation ([#364](https://github.com/MetaMask/snap-bitcoin-wallet/pull/364)) + - Bump `@metamask/keyring-api` from `^8.1.3` to `^13.0.0`. + - Compatible with `@metamask/eth-snap-keyring@^7.1.0`. +- Support for fee rate caching ([#358](https://github.com/MetaMask/snap-bitcoin-wallet/pull/358)) +- Use Snap UI update context instead of persisting request ([#345](https://github.com/MetaMask/snap-bitcoin-wallet/pull/345)) + - Making the Snap UI slighty more responsive and faster. +- Remove support of Blockchair service ([#298](https://github.com/MetaMask/snap-bitcoin-wallet/pull/298)) + +### Fixed + +- Various UI fixes ([#356](https://github.com/MetaMask/snap-bitcoin-wallet/pull/356)), ([#357](https://github.com/MetaMask/snap-bitcoin-wallet/pull/357)), ([#346](https://github.com/MetaMask/snap-bitcoin-wallet/pull/346)), ([#344](https://github.com/MetaMask/snap-bitcoin-wallet/pull/344)), ([#343](https://github.com/MetaMask/snap-bitcoin-wallet/pull/343)) +- Allow send to happen even when rates are not available ([#350](https://github.com/MetaMask/snap-bitcoin-wallet/pull/350)) + +## [0.8.2] + +### Fixed + +- Remove `localhost` from permissions ([#324](https://github.com/MetaMask/snap-bitcoin-wallet/pull/324)), ([#322](https://github.com/MetaMask/snap-bitcoin-wallet/pull/322)) + +## [0.8.1] + +### Fixed + +- Use `hideSnapBranding` flag when building ([#312](https://github.com/MetaMask/snap-bitcoin-wallet/pull/312)) + +## [0.8.0] + +### Added + +- Add send flow UI ([#281](https://github.com/MetaMask/snap-bitcoin-wallet/pull/281)), ([#309](https://github.com/MetaMask/snap-bitcoin-wallet/pull/309)), ([#308](https://github.com/MetaMask/snap-bitcoin-wallet/pull/308)), ([#305](https://github.com/MetaMask/snap-bitcoin-wallet/pull/305)), ([#304](https://github.com/MetaMask/snap-bitcoin-wallet/pull/304)), ([#289](https://github.com/MetaMask/snap-bitcoin-wallet/pull/289)) + - The send flow can be started using the `startSendTransactionFlow` internal method. + - The UI allows to send an amount to one recipient (according to its balance). + - There is a confirmation screen that summarize everything regarding the transaction (amount, recipient, fee estimation), once confirmed the transaction will be broadcasted to the blockchain. + - Calling the `sendBitcoin` account's method will trigger the confirmation screen too. + +### Changed + +- **BREAKING:** Rename `btc_sendmany` method to `sendBitcoin` ([#303](https://github.com/MetaMask/snap-bitcoin-wallet/pull/303)) + - The `comment` and `subtractFeeFrom` options have been removed too. +- **BREAKING:** Make transactions replaceable by default ([#297](https://github.com/MetaMask/snap-bitcoin-wallet/pull/297)) +- Rename proposed name to "Bitcoin" (was "Bitcoin Manager") ([#283](https://github.com/MetaMask/snap-bitcoin-wallet/pull/283)) +- Adds fee estimation fallback for `QuickNode` ([#269](https://github.com/MetaMask/snap-bitcoin-wallet/pull/269)) + - In some cases, `QuickNode` may fail to process fee estimation. In such instances, we fall back on using fee information directly from the mempool. + - We also added a default minimum fee as the last resort in case everything else failed. + +## [0.7.0] + +### Changed + +- Use `QuickNode` as the main provider ([#250](https://github.com/MetaMask/snap-bitcoin-wallet/pull/250)) +- Workaround `QuickNode` fee estimation for testnet ([#267](https://github.com/MetaMask/snap-bitcoin-wallet/pull/267)) + - We temporarily changed the confirmation target block to a higher block number to make sure the API is not failing with an error. + +### Fixed + +- Fix fee estimation with `QuickNode` ([#266](https://github.com/MetaMask/snap-bitcoin-wallet/pull/266)), ([#261](https://github.com/MetaMask/snap-bitcoin-wallet/pull/261)) + - Properly uses `kvB` instead of `vB`. + - Will **NOT** throw an error if the account has not enough UTXOs when estimating the fees. + +## [0.6.1] + +### Added + +- Add `QuickNode` API client ([#247](https://github.com/MetaMask/snap-bitcoin-wallet/pull/247)) + - This client is not yet used, we still use Blockchair provider for the moment. + +### Changed + +- Bump `@metamask/keyring-api` from `^8.0.2` to `^8.1.3` ([#253](https://github.com/MetaMask/snap-bitcoin-wallet/pull/253)) + - This version is now built slightly differently and is part of the [accounts monorepo](https://github.com/MetaMask/accounts). + +## [0.6.0] + +### Added + +- Display an alert dialog when an error happens during `btc_sendmany` ([#236](https://github.com/MetaMask/snap-bitcoin-wallet/pull/236)) + +### Changed + +- Use similar shorten-address format than the extension ([#238](https://github.com/MetaMask/snap-bitcoin-wallet/pull/238)) + +## [0.5.0] + +### Added + +- Add `getMaxSpendableBalance` RPC endpoint ([#188](https://github.com/MetaMask/snap-bitcoin-wallet/pull/188)) + +### Changed + +- Improve keyring tests coverage ([#220](https://github.com/MetaMask/snap-bitcoin-wallet/pull/220)) + +## [0.4.0] + +### Added + +- Emit account suggested name when creating account ([#210](https://github.com/MetaMask/snap-bitcoin-wallet/pull/210)) + - This name suggestion can then be used by the client to name the account accordingly. + +### Changed + +- Audit 4.6: Pin Bitcoin and cryptographic dependencies ([#205](https://github.com/MetaMask/snap-bitcoin-wallet/pull/205)) + +## [0.3.0] + +### Added + +- Add `estimateFee` RPC endpoint ([#169](https://github.com/MetaMask/snap-bitcoin-wallet/pull/169)) + +## [0.2.5] + +### Added + +- Add reusable error types ([#185](https://github.com/MetaMask/snap-bitcoin-wallet/pull/185)) + +### Changed + +- Use custom `superstruct` validator for `AmountStruct` ([#184](https://github.com/MetaMask/snap-bitcoin-wallet/pull/184)) + +### Fixed + +- Fix overridden message in `MethodNotFoundError` ([#189](https://github.com/MetaMask/snap-bitcoin-wallet/pull/189)) + +## [0.2.4] + +### Fixed + +- Audit 4.5: Show origin on `btc_sendmany` confirmation dialog ([#152](https://github.com/MetaMask/snap-bitcoin-wallet/pull/152)) +- Audit 4.7: Fix potential URL injections in Blockchair API calls ([#132](https://github.com/MetaMask/snap-bitcoin-wallet/pull/132)) +- Audit 4.11: Remove code for creating unsupported P2SHP2WPKH account type ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.12: Derive accounts with different HD path by network ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.19: Implement keyring method `filterAccountChains` ([#122](https://github.com/MetaMask/snap-bitcoin-wallet/pull/122)) + +## [0.2.3] + +### Changed + +- Change Snap name to "Bitcoin Manager" ([#158](https://github.com/MetaMask/snap-bitcoin-wallet/pull/158)) +- Change local dapp port ([#147](https://github.com/MetaMask/snap-bitcoin-wallet/pull/147)) + +### Fixed + +- Audit 4.2: Remove update account method ([#130](https://github.com/MetaMask/snap-bitcoin-wallet/pull/130)) +- Audit 4.3: Restrict permissions for Portfolio origin ([#131](https://github.com/MetaMask/snap-bitcoin-wallet/pull/131)) +- Audit 4.4: Restrict permissions for MetaMask origin ([#141](https://github.com/MetaMask/snap-bitcoin-wallet/pull/141)) +- Audit 4.8: Ensure that `request.method` in submit request is part of `account.methods` ([#133](https://github.com/MetaMask/snap-bitcoin-wallet/pull/133)) +- Audit 4.9: Validate non-hex string in `hexToBuffer` ([#134](https://github.com/MetaMask/snap-bitcoin-wallet/pull/134)) +- Audit 4.13: Add a safeguard for change output ([#135](https://github.com/MetaMask/snap-bitcoin-wallet/pull/135)) +- Audit 4.14: Rename `txHash` to `signedTransaction` ([#120](https://github.com/MetaMask/snap-bitcoin-wallet/pull/120)) +- Audit 4.20: Validate `headLength` and `tailLength` in `replaceMiddleChar` ([#138](https://github.com/MetaMask/snap-bitcoin-wallet/pull/138)) +- Audit 4.23: Disable logging for production builds ([#124](https://github.com/MetaMask/snap-bitcoin-wallet/pull/124)) +- Audit 4.24: Remove temporary create account endpoint ([#115](https://github.com/MetaMask/snap-bitcoin-wallet/pull/115)) + +## [0.2.2] + +### Changed + +- Remove unused env var ([#148](https://github.com/MetaMask/snap-bitcoin-wallet/pull/148)) + +### Fixed + +- Emit event on account creation ([#153](https://github.com/MetaMask/snap-bitcoin-wallet/pull/153)) + +## [0.2.1] + +### Fixed + +- Remove duplicate validation when getting balances ([#137](https://github.com/MetaMask/snap-bitcoin-wallet/pull/137)) + +## [0.2.0] + +### Added + +- Add 'ramps-dev.portfolio.metamask.io' origin ([#144](https://github.com/MetaMask/snap-bitcoin-wallet/pull/144)) +- Enable `getAccountBalances` method for Portfolio origins ([#126](https://github.com/MetaMask/snap-bitcoin-wallet/pull/126)) +- Implement Keyring API `getAccountBalances` method ([#84](https://github.com/MetaMask/snap-bitcoin-wallet/pull/84)) +- Implement Chain API `getTransactionStatus` method ([#85](https://github.com/MetaMask/snap-bitcoin-wallet/pull/85)) + +### Changed + +- Rename "Bitcoin Manager" to "Bitcoin Wallet" ([#142](https://github.com/MetaMask/snap-bitcoin-wallet/pull/142)) +- Improve `btc_sendMany` implementation ([#97](https://github.com/MetaMask/snap-bitcoin-wallet/pull/97)) +- Update `btc_sendMany` dialogs ([#83](https://github.com/MetaMask/snap-bitcoin-wallet/pull/83)) + +## [0.1.2] + +### Changed + +- fix: update change log format ([#76](https://github.com/MetaMask/bitcoin/pull/76)) +- fix: update package.json ([#76](https://github.com/MetaMask/bitcoin/pull/74)) + +## [0.1.1] + +### Added + +- fix: update resp of chainService - boardcastTransaction ([#68](https://github.com/MetaMask/bitcoin/pull/68)) +- feat: add auto install script ([#67](https://github.com/MetaMask/bitcoin/pull/67)) +- feat: add while list domain ([#71](https://github.com/MetaMask/bitcoin/pull/71)) +- chore: re structure ([#66](https://github.com/MetaMask/bitcoin/pull/66)) +- fix: change to keyring state method get wallet ([#62](https://github.com/MetaMask/bitcoin/pull/62)) +- feat: implement keyring api - btc_sendmany ([#65](https://github.com/MetaMask/bitcoin/pull/65)) +- chore: update snap provider instance name ([#69](https://github.com/MetaMask/bitcoin/pull/69)) +- build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 ([#50](https://github.com/MetaMask/bitcoin/pull/50)) +- feat: add bufferToString method ([#61](https://github.com/MetaMask/bitcoin/pull/61)) +- feat: add chain API - chain_broadcastTransaction ([#57](https://github.com/MetaMask/bitcoin/pull/57)) +- chore: add unit test for get balances ([#58](https://github.com/MetaMask/bitcoin/pull/58)) +- feat: add chain API - chain_getDataForTransaction ([#42](https://github.com/MetaMask/bitcoin/pull/42)) +- feat: add chain API - chain_estimateFees ([#18](https://github.com/MetaMask/bitcoin/pull/18)) +- feat: add keyring API - btc_sendmany skeleton ([#41](https://github.com/MetaMask/bitcoin/pull/41)) +- fix: update btc asset ([#44](https://github.com/MetaMask/bitcoin/pull/44)) +- feat: add ListAccountsButton card ([#43](https://github.com/MetaMask/bitcoin/pull/43)) +- chore: move config to factory ([#35](https://github.com/MetaMask/bitcoin/pull/35)) +- feat: add methods to support satoshi to btc, and btc to satoshi ([#34](https://github.com/MetaMask/bitcoin/pull/34)) +- chore: add commit method in state management ([#32](https://github.com/MetaMask/bitcoin/pull/32)) +- fix: restructure code ([#31](https://github.com/MetaMask/bitcoin/pull/31)) +- fix: remove non ready code ([#30](https://github.com/MetaMask/bitcoin/pull/30)) +- fix: fix snap publish package is not using builded snap config ([#29](https://github.com/MetaMask/bitcoin/pull/29)) +- feat: add api key for blockchair ([#27](https://github.com/MetaMask/bitcoin/pull/27)) +- chore: restructure the code repo to fit to chain api and keyring api structure ([#26](https://github.com/MetaMask/bitcoin/pull/26)) +- fix: refine coding structure ([#25](https://github.com/MetaMask/bitcoin/pull/25)) +- feat: support transactional state management ([#20](https://github.com/MetaMask/bitcoin/pull/20)) +- feat: add permission validation ([#24](https://github.com/MetaMask/bitcoin/pull/24)) +- fix: remove un use code in BaseSnapRpcHandler ([#19](https://github.com/MetaMask/bitcoin/pull/19)) +- feat: add validation on api response ([#17](https://github.com/MetaMask/bitcoin/pull/17)) +- feat: implement chain api - get balances ([#16](https://github.com/MetaMask/bitcoin/pull/16)) +- feat: setup init cd to publish snap to npm public registry ([#15](https://github.com/MetaMask/bitcoin/pull/15)) +- feat: implement chain api skeleton ([#14](https://github.com/MetaMask/bitcoin/pull/14)) +- feat: emit keyring event before state store ([#13](https://github.com/MetaMask/bitcoin/pull/13)) +- feat: implement keyring api ([#12](https://github.com/MetaMask/bitcoin/pull/12)) +- feat: add blockchair ([#11](https://github.com/MetaMask/bitcoin/pull/11)) +- chore: update keyring type and methods permission ([#10](https://github.com/MetaMask/bitcoin/pull/10)) +- chore: update snap icon ([#9](https://github.com/MetaMask/bitcoin/pull/9)) +- fix: the ci pipeline for metamask task issue ([#8](https://github.com/MetaMask/bitcoin/pull/8)) +- build(deps): bump @metamask/keyring-api from 5.1.0 to 6.0.0 ([#6](https://github.com/MetaMask/bitcoin/pull/6)) +- build(deps-dev): bump @metamask/snaps-jest from 6.0.2 to 7.0.2 ([#7](https://github.com/MetaMask/bitcoin/pull/7)) +- feat: add snap unit test ([#1](https://github.com/MetaMask/bitcoin/pull/1)) +- feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) +- feat: init commit + +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.15.0...HEAD +[1.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.2...v1.15.0 +[1.14.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.1...v1.14.2 +[1.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...v1.14.1 +[1.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...v1.14.0 +[1.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...v1.13.0 +[1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 +[1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 +[1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 +[1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 +[1.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...v1.9.0 +[1.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...v1.8.0 +[1.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...v1.7.0 +[1.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...v1.6.0 +[1.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...v1.5.0 +[1.4.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...v1.4.5 +[1.4.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...v1.4.4 +[1.4.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...v1.4.3 +[1.4.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...v1.4.2 +[1.4.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...v1.4.1 +[1.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...v1.0.0 +[0.19.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...v0.19.3 +[0.19.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...v0.19.2 +[0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 +[0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 +[0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 +[0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 +[0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 +[0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 +[0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 +[0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 +[0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 +[0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 +[0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 +[0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 +[0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 +[0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/MetaMask/snap-bitcoin-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md new file mode 100644 index 00000000..8849a024 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -0,0 +1,35 @@ +# Bitcoin Wallet Snap + +This package contains the source code for the Bitcoin Wallet Snap - a MetaMask Snap that enables Bitcoin blockchain functionality directly within your MetaMask wallet. The Snap allows users to: + +- Create and manage Bitcoin accounts accross all address types (p2pkh, p2sh, p2wpkh, p2tr) +- View BTC balances and history +- Sign transactions (PSBTs) +- Send and receive transactions +- Connect to Bitcoin dApps +- Support all networks (Bitcoin, Testnet3, Testnet4, Signet via Mutinynet and Regtest) + +The Snap is built using the MetaMask Snaps SDK and integrates with [Bitcoindevkit](https://bitcoindevkit.org/). It follows best practices for security and provides a seamless user experience within the familiar MetaMask interface. + +![Snap UI](./docs/ui.png) + +## Running the snap locally + +```bash +yarn start +``` + +> [!WARNING] +> When snap updates you will need to still reconnect from the dapp to see changes + +## Building + +```bash +yarn build:snap +``` + +Further reading: + +- [Development](../../docs/development.md) +- [Contributing](../../docs/contributing.md) +- [Releasing](../../docs/release.md) diff --git a/merged-packages/bitcoin-wallet-snap/babel.config.js b/merged-packages/bitcoin-wallet-snap/babel.config.js new file mode 100644 index 00000000..8165fe45 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/merged-packages/bitcoin-wallet-snap/docs/ui.png b/merged-packages/bitcoin-wallet-snap/docs/ui.png new file mode 100644 index 00000000..a738b7e4 Binary files /dev/null and b/merged-packages/bitcoin-wallet-snap/docs/ui.png differ diff --git a/merged-packages/bitcoin-wallet-snap/images/icon.svg b/merged-packages/bitcoin-wallet-snap/images/icon.svg new file mode 100644 index 00000000..a64d9c44 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/images/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts new file mode 100644 index 00000000..87f2e080 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts @@ -0,0 +1,177 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { execSync } from 'child_process'; +import process from 'process'; + +/* eslint-enable import-x/no-nodejs-modules */ + +/** + * Minimal utility for Bitcoin regtest operations in integration tests + */ +export class BlockchainTestUtils { + readonly #containerName: string; + + readonly #esploraHost: string; + + readonly #esploraPort: number; + + readonly #esploraBaseUrl: string; + + constructor(options?: { + containerName?: string; + esploraHost?: string; + esploraPort?: number; + }) { + this.#containerName = + options?.containerName ?? process.env.ESPLORA_CONTAINER ?? 'esplora'; + + this.#esploraHost = + options?.esploraHost ?? process.env.ESPLORA_HOST ?? 'localhost'; + + this.#esploraPort = + options?.esploraPort ?? + (process.env.ESPLORA_PORT + ? parseInt(process.env.ESPLORA_PORT, 10) + : 8094); + + this.#esploraBaseUrl = `http://${this.#esploraHost}:${this.#esploraPort}/regtest/api`; + } + + /** + * Execute a bitcoin-cli command in the Docker container + * + * @param command - The bitcoin-cli command to execute + * @returns The command output as a string + */ + #execCli(command: string): string { + const fullCommand = `docker exec ${this.#containerName} cli -regtest ${command}`; + try { + return execSync(fullCommand, { encoding: 'utf8' }).trim(); + } catch (error) { + throw new Error( + `Failed to execute CLI command: ${command}\n${String(error)}`, + ); + } + } + + /** + * Poll until Esplora has indexed up to a specific block height + * + * @param targetHeight - The block height to wait for + * @param maxRetries - Maximum number of polling attempts + */ + async #waitForEsploraHeight( + targetHeight: number, + maxRetries = 20, + ): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch( + `${this.#esploraBaseUrl}/blocks/tip/height`, + ); + if (response.ok) { + const height = parseInt(await response.text(), 10); + if (height >= targetHeight) { + return; + } + } + } catch { + // Esplora not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Esplora did not reach height ${targetHeight} after ${maxRetries} attempts`, + ); + } + + /** + * Wait for Esplora to see a transaction + * + * @param txid - The transaction ID to wait for + * @param maxRetries - Maximum number of polling attempts + */ + async #waitForEsploraTx(txid: string, maxRetries = 20): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(`${this.#esploraBaseUrl}/tx/${txid}`); + if (response.ok) { + return; + } + } catch { + // Not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Esplora did not index transaction ${txid} after ${maxRetries} attempts`, + ); + } + + /** + * Send Bitcoin to an address and wait for Esplora to see it + * + * @param address - The Bitcoin address to send to + * @param amount - The amount of BTC to send + * @returns The transaction ID + */ + async sendToAddress(address: string, amount: number): Promise { + const txid = this.#execCli( + `-rpcwallet=default sendtoaddress "${address}" ${amount}`, + ); + + await this.#waitForEsploraTx(txid); + return txid; + } + + /** + * Mine blocks and wait for Esplora to index them + * + * @param count - The number of blocks to mine + */ + async mineBlocks(count: number): Promise { + const currentHeight = parseInt(this.#execCli('getblockcount'), 10); + const targetHeight = currentHeight + count; + + const minerAddress = this.#execCli('getnewaddress'); + this.#execCli(`generatetoaddress ${count} ${minerAddress}`); + + await this.#waitForEsploraHeight(targetHeight); + } + + /** + * Get the balance of a Bitcoin address in satoshis + * + * @param address - The Bitcoin address to query + * @returns The balance in satoshis + */ + async getBalance(address: string): Promise { + try { + const response = await fetch( + `${this.#esploraBaseUrl}/address/${address}`, + ); + if (!response.ok) { + throw new Error(`Failed to get address info: ${response.statusText}`); + } + + const addressInfo = await response.json(); + const funded = BigInt(addressInfo.chain_stats.funded_txo_sum ?? 0); + const spent = BigInt(addressInfo.chain_stats.spent_txo_sum ?? 0); + return funded - spent; + } catch (error) { + throw new Error( + `Failed to get balance for address ${address}: ${String(error)}`, + ); + } + } + + /** + * Get the balance of a Bitcoin address in BTC + * + * @param address - The Bitcoin address to query + * @returns The balance in BTC as a number + */ + async getBalanceInBTC(address: string): Promise { + const balanceSats = await this.getBalance(address); + return Number(balanceSats) / 100_000_000; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts new file mode 100644 index 00000000..2cb56a67 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -0,0 +1,444 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { FeeType, BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN, TEST_ADDRESS_REGTEST } from './constants'; +import { CurrencyUnit, TrackingSnapEvent } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; + +const ACCOUNT_INDEX = 1; + +describe('OnClientRequestHandler', () => { + let account: KeyringAccount; + let snap: Snap; + let blockchain: BlockchainTestUtils; + let createdAccountId: string | undefined; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + + // mock snap_manageAccounts to handle different sub-methods + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return createdAccountId ? [createdAccountId] : []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + if (request.method === 'snap_dialog') { + return true; + } + + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + + // don't mock other methods + return undefined; + }); + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + synchronize: false, + index: ACCOUNT_INDEX, + }, + }, + }); + + if ('result' in response.response) { + account = response.response.result as KeyringAccount; + createdAccountId = account.id; + } + + await blockchain.sendToAddress(account.address, 10); + await blockchain.mineBlocks(6); + await snap.onCronjob({ method: 'synchronizeAccounts' }); + }); + + it('fills inputs, signs and sends an output-only PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'signAndSendTransaction', + params: { + accountId: account.id, + transaction: + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', + }, + }); + + expect(response).toRespondWith({ + transactionId: expect.any(String), + canBeMalleable: false, + }); + const { transactionId } = ( + response.response as { result: { transactionId: string } } + ).result; + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(response).toTrackEvent({ + event: TrackingSnapEvent.TransactionSubmitted, + properties: { + account_type: BtcAccountType.P2wpkh, + chain_id_caip: BtcScope.Regtest, + message: 'Snap transaction submitted', + origin: ORIGIN, + tx_id: transactionId, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + await blockchain.mineBlocks(6); + + // should now detect transaction as finalised + const finalSyncResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + + expect(finalSyncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(finalSyncResponse).toTrackEvent({ + event: TrackingSnapEvent.TransactionFinalized, + properties: { + origin: 'cron', + message: 'Snap transaction finalized', + chain_id_caip: BtcScope.Regtest, + account_type: BtcAccountType.P2wpkh, + tx_id: transactionId, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + it('fails if incorrect PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'signAndSendTransaction', + params: { + accountId: account.id, + transaction: 'notAPsbt', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails if missing params', async () => { + const response = await snap.onClientRequest({ + method: 'signAndSendTransaction', + params: { + accountId: null, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: accountId -- Expected a string, but received: null', + stack: expect.anything(), + }); + }); + + it('computes fee for valid PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: account.id, + transaction: + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', + scope: BtcScope.Regtest, + }, + }); + + expect(response).toRespondWith([ + { + type: FeeType.Priority, + asset: { + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + amount: expect.stringContaining('0.00001'), + fungible: true, + }, + }, + ]); + }); + + it('fails to compute fee for invalid PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: account.id, + transaction: 'notAPsbt', + scope: BtcScope.Regtest, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails to compute fee if missing params', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: null, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: accountId -- Expected a string, but received: null', + stack: expect.anything(), + }); + }); + + it('validates a valid regtest address', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: TEST_ADDRESS_REGTEST, + accountId: account.id, + }, + }); + + expect(response).toRespondWith({ + valid: true, + errors: [], + }); + }); + + it('rejects an invalid address format', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'not-a-valid-bitcoin-address', + accountId: account.id, + }, + }); + + expect(response).toRespondWith({ + valid: false, + errors: [{ code: 'Invalid' }], + }); + }); + + it('rejects a testnet address when using regtest account', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'tb1qrn9d5qewjqq5syc4nrjprkfq8gge0cjdaznwcn', // testnet address (tb1) + accountId: account.id, // regtest account + }, + }); + + expect(response).toRespondWith({ + valid: false, + errors: [{ code: 'Invalid' }], + }); + }); + + it('missing accountId for onAddressInput', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'tb1qrn9d5qewjqq5syc4nrjprkfq8gge0cjdaznwcn', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: accountId -- Expected a string, but received: undefined', + stack: expect.anything(), + }); + }); + + describe('confirmSend', () => { + it('creates a transaction without broadcasting', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '0.001', // 0.001 BTC + }, + }); + + expect(response).toRespondWith({ + type: 'send', + id: expect.any(String), + account: account.id, + chain: BtcScope.Regtest, + status: 'unconfirmed', + timestamp: expect.any(Number), + events: [ + { + status: 'unconfirmed', + timestamp: expect.any(Number), + }, + ], + to: [ + { + address: TEST_ADDRESS_REGTEST, + asset: { + amount: '0.001', // BTC amount + fungible: true, + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + }, + }, + ], + from: [], + fees: [ + { + type: FeeType.Priority, + asset: { + amount: expect.any(String), + fungible: true, + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + }, + }, + ], + canBeMalleable: false, + }); + }); + + it('fails with invalid account ID', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: 'not-a-uuid', + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '0.001', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: expect.stringContaining('Expected a string matching'), + stack: expect.anything(), + }); + }); + + it('fails with invalid address', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: 'invalid-address', + assetId: Caip19Asset.Regtest, + amount: '0.001', + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'Invalid' }], + valid: false, + }); + }); + + it('fails with invalid amount', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '-0.001', // negative amount + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'Invalid' }], + valid: false, + }); + }); + + it('fails with insufficient funds', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '1000', // 1000 BTC - more than available + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'InsufficientBalance' }], + valid: false, + }); + }); + + it.skip('fails with insufficient funds to pay fees', async () => { + // now with drainWallet in place this is not going to happen + const balanceBtc = await blockchain.getBalanceInBTC(account.address); + + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: balanceBtc.toString(), + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'InsufficientBalanceToCoverFee' }], + valid: false, + }); + }); + + it('fails with missing parameters', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + // missing toAddress, assetId, amount + } as any, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: expect.stringContaining('At path:'), + stack: expect.anything(), + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts new file mode 100644 index 00000000..e19d50f1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -0,0 +1,52 @@ +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; + +import { CurrencyUnit } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; + +export const MNEMONIC = + 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose'; +export const TEST_ADDRESS_REGTEST = + 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t'; +export const TEST_ADDRESS_MAINNET = + 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q'; +export const ORIGIN = 'metamask'; +export const FUNDING_TX = { + account: expect.any(Number), + chain: BtcScope.Regtest, + events: [ + { status: 'unconfirmed', timestamp: null }, + { status: 'confirmed', timestamp: expect.any(Number) }, + ], + fees: [], + from: [], + id: expect.any(String), + status: 'confirmed', + timestamp: expect.any(Number), + to: [ + { + address: TEST_ADDRESS_REGTEST, + asset: { + amount: '500', + fungible: true, + type: Caip19Asset.Regtest, + unit: CurrencyUnit.Regtest, + }, + }, + ], + type: 'receive', +}; + +export const accountTypeToPurpose: Record = { + [BtcAccountType.P2pkh]: "44'", + [BtcAccountType.P2sh]: "49'", + [BtcAccountType.P2wpkh]: "84'", + [BtcAccountType.P2tr]: "86'", +}; + +export const scopeToCoinType: Record = { + [BtcScope.Mainnet]: "0'", + [BtcScope.Testnet]: "1'", + [BtcScope.Testnet4]: "1'", + [BtcScope.Signet]: "1'", + [BtcScope.Regtest]: "1'", +}; diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts new file mode 100644 index 00000000..d90b6558 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -0,0 +1,130 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN } from './constants'; +import { TrackingSnapEvent } from '../src/entities'; + +const ACCOUNT_INDEX = 2; + +describe('CronHandler', () => { + let snap: Snap; + let blockchain: BlockchainTestUtils; + const accountsToSync: string[] = []; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + }); + + beforeEach(() => { + // clear accounts list before each test + accountsToSync.length = 0; + + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return [...accountsToSync]; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + + return undefined; + }); + }); + + it('should synchronize the account', async () => { + // sanity test + const response = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + expect(response).toBeDefined(); + }); + + it('tracks TransactionReceived for new unconfirmed transaction with multiple syncs', async () => { + // create account without initial sync + const createResponse = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + synchronize: false, + index: ACCOUNT_INDEX, + }, + }, + }); + + expect(createResponse.response).toBeDefined(); + expect('result' in createResponse.response).toBe(true); + + const account = (createResponse.response as { result: KeyringAccount }) + .result; + + accountsToSync.push(account.id); + + // send a new transaction to the new account + let txid = await blockchain.sendToAddress(account.address, 10); + expect(txid).toBeDefined(); + + // run cron sync to discover the unconfirmed transaction + let syncResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + expect(syncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(syncResponse).toTrackEvent({ + event: 'Transaction Received', + properties: { + origin: 'cron', + message: 'Snap transaction received', + chain_id_caip: BtcScope.Regtest, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + // send a transaction to the account + txid = await blockchain.sendToAddress(account.address, 5); + expect(txid).toBeDefined(); + + // sync using syncSelectedAccounts + syncResponse = await snap.onCronjob({ + method: 'syncSelectedAccounts', + params: { accountIds: [account.id] }, + }); + + expect(syncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(syncResponse).toTrackEvent({ + event: TrackingSnapEvent.TransactionReceived, + properties: { + origin: 'metamask', + message: 'Snap transaction received', + chain_id_caip: BtcScope.Regtest, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/docker-compose.yml b/merged-packages/bitcoin-wallet-snap/integration-test/docker-compose.yml new file mode 100644 index 00000000..3c1c6bcd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/docker-compose.yml @@ -0,0 +1,13 @@ +services: + esplora: + image: blockstream/esplora + container_name: esplora + ports: + - "50001:50001" + - "8094:80" + volumes: + - ./init-esplora.sh:/init-esplora.sh + entrypoint: + - bash + - -c + - "/srv/explorer/run.sh bitcoin-regtest explorer" diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/init-esplora.sh b/merged-packages/bitcoin-wallet-snap/integration-test/init-esplora.sh new file mode 100755 index 00000000..de73aed1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/init-esplora.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +cli -regtest loadwallet default || true +MINER_ADDRESS=$(cli -regtest getnewaddress) +cli -regtest generatetoaddress 100 "$MINER_ADDRESS" + +RECEIVER_ADDRESS="bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t" + +AMOUNT=500.0 +TXID=$(cli -regtest -rpcwallet=default sendtoaddress "$RECEIVER_ADDRESS" $AMOUNT) +echo "Transaction sent. TXID: $TXID" + +echo "Mining 10 blocks to confirm transaction..." +cli -regtest generatetoaddress 10 "$MINER_ADDRESS" + +echo "Setup complete. Funds sent to $RECEIVER_ADDRESS." \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts new file mode 100644 index 00000000..9f84bb52 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -0,0 +1,783 @@ +import type { KeyringAccount, KeyringRequest } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN } from './constants'; +import { AccountCapability } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; +import type { FillPsbtResponse } from '../src/handlers/KeyringRequestHandler'; + +const ACCOUNT_INDEX = 3; +const submitRequestMethod = 'keyring_submitRequest'; + +describe('KeyringRequestHandler', () => { + let account: KeyringAccount; + let snap: Snap; + let blockchain: BlockchainTestUtils; + const origin = 'http://my-dapp.com'; + let createdAccountId: string | undefined; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return createdAccountId ? [createdAccountId] : []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + + // no mocking for other methods + return undefined; + }); + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + synchronize: false, + index: ACCOUNT_INDEX, + }, + }, + }); + + if ('result' in response.response) { + account = response.response.result as KeyringAccount; + createdAccountId = account.id; + } + + await blockchain.sendToAddress(account.address, 10); + await blockchain.mineBlocks(6); + await snap.onCronjob({ method: 'synchronizeAccounts' }); + }); + + it('fails if invalid params', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: 'notAUUID', + request: { + method: AccountCapability.SignPsbt, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: params.account -- Expected a value of type `UuidV4`, but received: `"notAUUID"`', + stack: expect.anything(), + }); + }); + + it('fails if unrecognized method', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: 'invalidMethod', + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32601, + data: { + account: account.id, + cause: null, + method: 'invalidMethod', + }, + message: + 'Method not implemented or not supported: Unrecognized Bitcoin account capability', + stack: expect.anything(), + }); + }); + + // Keep order of tests as UTXOs are modified by other tests + describe('UTXO management', () => { + it('listUtxos', async () => { + let response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ListUtxos, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: [ + { + address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + derivationIndex: 0, + outpoint: expect.any(String), + scriptPubkey: + 'OP_0 OP_PUSHBYTES_20 82932f60427f769dfab2f449c91b4d9e94a8edb4', + scriptPubkeyHex: '001482932f60427f769dfab2f449c91b4d9e94a8edb4', + value: '1000000000', + }, + ], + }); + + const utxos = ( + response.response as { result: { result: { outpoint: string }[] } } + ).result.result; + + response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.GetUtxo, + params: { + account: { address: account.address }, + outpoint: utxos[0]?.outpoint, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: utxos[0], + }); + }); + + it('publicDescriptor', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.PublicDescriptor, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: + "wpkh([27f9035f/84'/1'/0']tpubDCkv2fHDfPg5ok9EPv6CDozH72rvY2jgEPm79szMeBwCBwUf2T6n5nLrWFfhuuD48SgzrELezoiyDM9KbZaVen4wuuGwrqQANDhzB7E8yDh/0/*)#sx899xk6", + }); + }); + }); + + describe('signPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + const SIGNED_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAA=='; + + it('signs a PSBT successfully: sign', async () => { + const response = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: false, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ + pending: false, + result: { + psbt: SIGNED_PSBT, + txid: null, + }, + }); + }); + + it('signs a PSBT successfully: fill and sign', async () => { + const response = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + txid: null, + }, + }); + }); + + it('signs a PSBT successfully: fill, sign and broadcast', async () => { + const response = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + txid: expect.any(String), + canBeMalleable: false, + }, + }); + + // Regression for issue #597: after broadcasting a partial-spend tx + // whose change lands on the external (public) keychain — as bridges + // and swaps do — the displayed balance must include that unconfirmed + // change. The wallet was funded with a single 10 BTC UTXO in + // `beforeAll`; the broadcast spends a small amount plus fee, so the + // change should be ~9.99 BTC, never 0. + const balanceResp = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_getAccountBalances', + params: { + id: account.id, + assets: [Caip19Asset.Regtest], + }, + }); + + const balanceResult = ( + balanceResp.response as { + result: Record; + } + ).result; + const amount = balanceResult[Caip19Asset.Regtest]?.amount; + expect(amount).toBeDefined(); + // Bound from above as well: a stale fix where applyUnconfirmedTx + // no-ops would still report the original 10 BTC and pass a `> 9` + // check on its own, hiding the regression. + expect(parseFloat(amount as string)).toBeGreaterThan(9); + expect(parseFloat(amount as string)).toBeLessThan(10); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: 'notAPsbt', + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails if missing account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: account -- Expected an object, but received: undefined', + stack: expect.anything(), + }); + }); + + it('fails if missing options', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: options -- Expected an object, but received: undefined', + stack: expect.anything(), + }); + }); + }); + + describe('fillPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('fills a PSBT successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.FillPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.FillPsbt, + params: { + account: { address: account.address }, + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); + + describe('computeFee', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('computes the fee for a PSBT successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ComputeFee, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + fee: '632', + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ComputeFee, + params: { + account: { address: account.address }, + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); + + describe('broadcastPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('broadcasts a PSBT successfully', async () => { + // Prepare the PSBT to broadcast so we have a valid PSBT to broadcast + const signResponse = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + account: { address: account.address }, + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + const signUi = await signResponse.getInterface(); + assertIsConfirmationDialog(signUi); + await signUi.ok(); + + const signResult = await signResponse; + + const { result } = ( + signResult.response as { result: { result: FillPsbtResponse } } + ).result; + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + account: { address: account.address }, + psbt: result.psbt, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + txid: expect.any(String), + canBeMalleable: false, + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + account: { address: account.address }, + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); + + describe('sendTransfer', () => { + it('sends funds successfully', async () => { + const response = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SendTransfer, + params: { + account: { address: account.address }, + recipients: [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ], + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ + pending: false, + result: { + txid: expect.any(String), + canBeMalleable: false, + }, + }); + }); + + it('fails if invalid recipients', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SendTransfer, + params: { + account: { address: account.address }, + recipients: [{ address: 'notAnAddress', amount: '1000' }], + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32602, + data: { address: 'notAnAddress', amount: '1000', cause: null }, + message: 'Validation failed: Invalid recipient', + stack: expect.anything(), + }); + }); + }); + + describe('signMessage', () => { + it('signs a message successfully', async () => { + const response = snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignMessage, + params: { + account: { address: account.address }, + message: 'Hello, world!', + }, + }, + } as KeyringRequest, + }); + + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ + pending: false, + result: { + signature: + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', + }, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts new file mode 100644 index 00000000..b092ae3c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -0,0 +1,515 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { + FUNDING_TX, + MNEMONIC, + ORIGIN, + TEST_ADDRESS_REGTEST, + TEST_ADDRESS_MAINNET, + scopeToCoinType, + accountTypeToPurpose, +} from './constants'; +import { AccountCapability, CurrencyUnit } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; + +const ACCOUNT_INDEX = 0; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +describe('Keyring', () => { + const accounts: Record = {}; // accounts stored by address + let snap: Snap; + + beforeAll(async () => { + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + }); + + beforeEach(() => { + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + + return undefined; + }); + }); + + it('discover accounts successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_discoverAccounts', + params: { + scopes: [BtcScope.Regtest], // avoid using other networks than Regtest as real external calls will be performed + entropySource: 'm', // we don't know the real entropy source so "m" acts as the default + groupIndex: 0, + }, + }); + + // We should get 1 account, the p2wpkh one of Regtest + expect(response).toRespondWith([ + { + type: 'bip44', + scopes: [BtcScope.Regtest], + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, + }, + ]); + }); + + it('creates discovered account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, + scope: BtcScope.Regtest, + synchronize: true, + }, + }, + }); + + expect(response).toRespondWith({ + type: BtcAccountType.P2wpkh, + id: expect.anything(), + address: TEST_ADDRESS_REGTEST, + options: { + entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: ACCOUNT_INDEX, + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, + }, + exportable: false, + }, + scopes: [BtcScope.Regtest], + methods: Object.values(AccountCapability), + }); + + // eslint-disable-next-line jest/no-conditional-in-test + if ('result' in response.response) { + accounts[TEST_ADDRESS_REGTEST] = response.response + .result as KeyringAccount; + + await snap.onCronjob({ + method: 'fullScanAccount', + params: { accountId: accounts[TEST_ADDRESS_REGTEST].id }, + }); + } + }); + + it.each([ + { + // tests creation of multiple accounts of same address type and network + addressType: BtcAccountType.P2wpkh, + scope: BtcScope.Regtest, + index: ACCOUNT_INDEX + 1, // index incremented by 1 + expectedAddress: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + }, + { + addressType: BtcAccountType.P2wpkh, + scope: BtcScope.Mainnet, + index: 0, + expectedAddress: TEST_ADDRESS_MAINNET, + }, + ])( + 'creates a P2WPKH account: %s', + async ({ expectedAddress, ...requestOpts }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { options: { ...requestOpts, synchronize: false } }, + }); + + expect(response).toRespondWith({ + type: requestOpts.addressType, + id: expect.anything(), + address: expectedAddress, + options: { + entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: requestOpts.index, + derivationPath: `m/${accountTypeToPurpose[requestOpts.addressType]}/${scopeToCoinType[requestOpts.scope]}/${requestOpts.index}'`, + }, + exportable: false, + }, + scopes: [requestOpts.scope], + methods: Object.values(AccountCapability), + }); + + // eslint-disable-next-line jest/no-conditional-in-test + if ('result' in response.response) { + accounts[expectedAddress] = response.response.result as KeyringAccount; + } + }, + ); + + // skip non-P2WPKH address types as we are not supporting them for v1 + it.skip.each([ + { + addressType: BtcAccountType.P2pkh, + scope: BtcScope.Mainnet, + index: 0, + expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', + }, + { + addressType: BtcAccountType.P2pkh, + scope: BtcScope.Testnet, + index: 0, + expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', + }, + { + addressType: BtcAccountType.P2sh, + scope: BtcScope.Mainnet, + index: 0, + expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', + }, + { + addressType: BtcAccountType.P2sh, + scope: BtcScope.Testnet, + index: 0, + expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', + }, + { + addressType: BtcAccountType.P2tr, + scope: BtcScope.Mainnet, + index: 0, + expectedAddress: + 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', + }, + { + addressType: BtcAccountType.P2tr, + scope: BtcScope.Testnet, + index: 0, + expectedAddress: + 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', + }, + ])('creates an account: %s', async ({ expectedAddress, ...requestOpts }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { options: { ...requestOpts, synchronize: false } }, + }); + + expect(response).toRespondWith({ + type: requestOpts.addressType, + id: expect.anything(), + address: expectedAddress, + options: { + entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: requestOpts.index, + derivationPath: `m/${accountTypeToPurpose[requestOpts.addressType]}/${scopeToCoinType[requestOpts.scope]}/${requestOpts.index}'`, + }, + exportable: false, + }, + scopes: [requestOpts.scope], + methods: Object.values(AccountCapability), + }); + + // eslint-disable-next-line jest/no-conditional-in-test + if ('result' in response.response) { + accounts[expectedAddress] = response.response.result as KeyringAccount; + } + }); + + it('returns the same account if already exists by derivationPath', async () => { + // Account already exists so we should get the same account + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/1'/0'", + }, + }, + }); + + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); + }); + + it('returns the same account if already exists', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + index: ACCOUNT_INDEX, + }, + }, + }); + + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); + }); + + it.each([ + { + addressType: BtcAccountType.P2pkh, + scope: BtcScope.Mainnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + { + addressType: BtcAccountType.P2sh, + scope: BtcScope.Testnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + { + addressType: BtcAccountType.P2tr, + scope: BtcScope.Mainnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + ])( + 'rejects creation of non-P2WPKH account: $addressType', + async ({ addressType, scope, expectedError }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope, + addressType, + index: 0, + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: `Invalid format: ${expectedError}`, + }, + }); + }, + ); + + it.each([ + { + derivationPath: "m/44'/0'/0'", // (P2PKH) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + { + derivationPath: "m/49'/0'/0'", // (P2SH) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + { + derivationPath: "m/86'/0'/0'", // (P2TR) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + ])( + 'rejects creation with non-BIP84 derivation path: $derivationPath', + async ({ derivationPath, expectedError }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + derivationPath, + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: `Invalid format: ${expectedError}`, + }, + }); + }, + ); + + it('rejects creation when addressType and derivationPath mismatch', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, // Native segwit + derivationPath: "m/44'/0'/0'", // Legacy path (P2PKH) + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: + 'Invalid format: Only native segwit (BIP-84) derivation paths are supported', + }, + }); + }); + + it('accepts creation when addressType and derivationPath both indicate P2WPKH', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/1'/10'", // Native segwit path matching P2WPKH + synchronize: false, + }, + }, + }); + + expect(response.response).toHaveProperty('result'); + + const account: KeyringAccount = ( + response.response as { result: KeyringAccount } + ).result; + expect(account.address).toMatch(/^bcrt1/u); // Native segwit address + expect((account.options.entropy as { groupIndex: number }).groupIndex).toBe( + 10, + ); + + // remove to avoid interfering with other tests + await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_deleteAccount', + params: { + id: account.id, + }, + }); + }); + + it('gets an account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_getAccount', + params: { + id: accounts[TEST_ADDRESS_REGTEST]!.id, + }, + }); + + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); + }); + + it('lists all accounts', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccounts', + }); + + expect(response).toRespondWith(Object.values(accounts)); + }); + + it('lists account transactions', async () => { + const accoundId = accounts[TEST_ADDRESS_REGTEST]!.id; + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccountTransactions', + params: { + id: accoundId, + pagination: { limit: 10, next: null }, + }, + }); + + expect(response).toRespondWith({ + data: [{ ...FUNDING_TX, account: accoundId }], + next: null, + }); + }); + + it('gets an account balance', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_getAccountBalances', + params: { + id: accounts[TEST_ADDRESS_REGTEST]!.id, + assets: [Caip19Asset.Regtest], + }, + }); + + expect(response).toRespondWith({ + [Caip19Asset.Regtest]: { + amount: '500', + unit: CurrencyUnit.Regtest, + }, + }); + }); + + it.each([ + { + address: TEST_ADDRESS_REGTEST, + expectedAssets: [Caip19Asset.Regtest], + }, + { + address: TEST_ADDRESS_MAINNET, + expectedAssets: [Caip19Asset.Bitcoin], + }, + ])('lists account assets: %s', async ({ address, expectedAssets }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccountAssets', + params: { + id: accounts[address]!.id, + }, + }); + + expect(response).toRespondWith(expectedAssets); + }); + + it('removes an account', async () => { + const { id } = accounts[TEST_ADDRESS_REGTEST]!; + + let response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_deleteAccount', + params: { + id, + }, + }); + + expect(response).toRespondWith(null); + + response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_getAccount', + params: { + id, + }, + }); + + expect(response).toRespondWithError({ + code: -32001, + message: `Resource not found: Account not found`, + data: { id, cause: null }, + stack: expect.anything(), + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh new file mode 100755 index 00000000..1fcd07f3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -e + +cleanup() { + echo "Stopping Docker services..." + docker-compose -f integration-test/docker-compose.yml down +} +trap cleanup EXIT + +echo "Starting Docker services..." +docker-compose -f integration-test/docker-compose.yml up -d + +# Check if Docker services started successfully +if [ $? -ne 0 ]; then + echo "Error: Failed to start Docker services" + exit 1 +fi + +echo "Docker services started successfully." + +# Show Docker service status +docker-compose -f integration-test/docker-compose.yml ps + +echo "Waiting for Esplora to be ready..." +sleep 5 + +# Transfer funds to test address +docker exec esplora bash /init-esplora.sh + +echo "Running integration tests..." +set +e +jest --config jest.integration.config.js +TEST_EXIT_CODE=$? +set -e +exit $TEST_EXIT_CODE diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js new file mode 100644 index 00000000..e8bd8785 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -0,0 +1,42 @@ +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['.*/index\\.ts'], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'babel', + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ['text', 'html', 'json-summary', 'lcov'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 65.5, + functions: 62.64, + lines: 75.29, + statements: 74.57, + }, + }, + + preset: '@metamask/snaps-jest', + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, + resetMocks: true, + testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], +}; + +export default config; diff --git a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js new file mode 100644 index 00000000..4c82e226 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js @@ -0,0 +1,11 @@ +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + preset: '@metamask/snaps-jest', + testMatch: ['**/integration-test/**/*.test.ts'], +}; + +export default config; + diff --git a/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json new file mode 100644 index 00000000..1e1bea97 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json @@ -0,0 +1,315 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Bitcoin Keyring SubmitRequest API", + "description": "Methods available through the submitRequest endpoint of the Bitcoin Wallet Snap keyring. These are intended for dApp developers. Each method is called by passing it as request.method in the submitRequest params, with request.params containing the method parameters. The account ID is specified in the top-level account field of the submitRequest params.", + "version": "1.0.0" + }, + "methods": [ + { + "name": "signPsbt", + "description": "Signs a partially signed Bitcoin transaction (PSBT) and optionally fills/broadcasts it.", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number", "minimum": 1 } + }, + { + "name": "options", + "description": "Options for filling and broadcasting.", + "required": true, + "schema": { + "type": "object", + "properties": { + "fill": { "type": "boolean" }, + "broadcast": { "type": "boolean" } + }, + "required": ["fill", "broadcast"] + } + } + ], + "result": { + "name": "signPsbtResponse", + "description": "The signed PSBT and optional txid if broadcast.", + "schema": { + "type": "object", + "properties": { + "psbt": { "type": "string" }, + "txid": { "type": ["string", "null"] } + }, + "required": ["psbt", "txid"] + } + } + }, + { + "name": "fillPsbt", + "description": "Fills a partially signed Bitcoin transaction (PSBT) with inputs and outputs.", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number", "minimum": 1 } + } + ], + "result": { + "name": "fillPsbtResponse", + "description": "The filled PSBT.", + "schema": { + "type": "object", + "properties": { + "psbt": { "type": "string" } + }, + "required": ["psbt"] + } + } + }, + { + "name": "computeFee", + "description": "Computes the fee for a partially signed Bitcoin transaction (PSBT).", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number", "minimum": 1 } + } + ], + "result": { + "name": "computeFeeResponse", + "description": "The computed fee in satoshis.", + "schema": { + "type": "object", + "properties": { + "fee": { "type": "string" } + }, + "required": ["fee"] + } + } + }, + { + "name": "broadcastPsbt", + "description": "Broadcasts a signed Bitcoin transaction (PSBT).", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded signed PSBT string.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "broadcastPsbtResponse", + "description": "The transaction ID.", + "schema": { + "type": "object", + "properties": { + "txid": { "type": "string" } + }, + "required": ["txid"] + } + } + }, + { + "name": "sendTransfer", + "description": "Creates, signs, and broadcasts a transfer to multiple recipients.", + "paramStructure": "by-name", + "params": [ + { + "name": "recipients", + "description": "Array of recipients with address and amount.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { "type": "string" }, + "amount": { "type": "string" } + }, + "required": ["address", "amount"] + } + } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number", "minimum": 1 } + } + ], + "result": { + "name": "sendTransferResponse", + "description": "The transaction ID.", + "schema": { + "type": "object", + "properties": { + "txid": { "type": "string" } + }, + "required": ["txid"] + } + } + }, + { + "name": "getUtxo", + "description": "Retrieves a specific UTXO by outpoint.", + "paramStructure": "by-name", + "params": [ + { + "name": "outpoint", + "description": "The UTXO outpoint in txid:vout format.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "getUtxoResponse", + "description": "The UTXO details.", + "schema": { + "type": "object", + "properties": { + "outpoint": { + "type": "string", + "description": "Outpoint of the utxo in the format :" + }, + "value": { + "type": "string", + "description": "Value of output in satoshis" + }, + "derivationIndex": { + "type": "number", + "description": "Derivation index" + }, + "scriptPubkey": { + "type": "string", + "description": "scriptPubkey in ASM format" + }, + "scriptPubkeyHex": { + "type": "string", + "description": "scriptPubkey in hex format" + }, + "address": { + "type": "string", + "description": "Address if the script can be represented as one (optional)" + } + }, + "required": [ + "outpoint", + "value", + "derivationIndex", + "scriptPubkey", + "scriptPubkeyHex" + ] + } + } + }, + { + "name": "listUtxos", + "description": "Lists all unspent UTXOs for the account.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "listUtxosResponse", + "description": "Array of UTXO details.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outpoint": { + "type": "string", + "description": "Outpoint of the utxo in the format :" + }, + "value": { + "type": "string", + "description": "Value of output in satoshis" + }, + "derivationIndex": { + "type": "number", + "description": "Derivation index" + }, + "scriptPubkey": { + "type": "string", + "description": "scriptPubkey in ASM format" + }, + "scriptPubkeyHex": { + "type": "string", + "description": "scriptPubkey in hex format" + }, + "address": { + "type": "string", + "description": "Address if the script can be represented as one (optional)" + } + }, + "required": [ + "outpoint", + "value", + "derivationIndex", + "scriptPubkey", + "scriptPubkeyHex" + ] + } + } + } + }, + { + "name": "publicDescriptor", + "description": "Retrieves the public descriptor for the account.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "publicDescriptorResponse", + "description": "The public descriptor string.", + "schema": { "type": "string" } + } + }, + { + "name": "signMessage", + "description": "Signs a message with the account's private key.", + "paramStructure": "by-name", + "params": [ + { + "name": "message", + "description": "The message to sign.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "signMessageResponse", + "description": "The signature.", + "schema": { + "type": "object", + "properties": { + "signature": { "type": "string" } + }, + "required": ["signature"] + } + } + } + ] +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/de.json b/merged-packages/bitcoin-wallet-snap/locales/de.json new file mode 100644 index 00000000..dddeb658 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/de.json @@ -0,0 +1,221 @@ +{ + "locale": "de", + "messages": { + "reviewTransactionWarning": { + "message": "Überprüfen Sie die Transaktion, bevor Sie fortfahren" + }, + "from": { + "message": "Von" + }, + "toAddress": { + "message": "An" + }, + "continue": { + "message": "Fortfahren" + }, + "cancel": { + "message": "Abbrechen" + }, + "clear": { + "message": "Löschen" + }, + "amount": { + "message": "Betrag" + }, + "balance": { + "message": "Guthaben" + }, + "recipient": { + "message": "Empfänger" + }, + "network": { + "message": "Netzwerk" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaktionsgeschwindigkeit" + }, + "transactionSpeedTooltip": { + "message": "Die geschätzte Zeit der Transaktion" + }, + "networkFee": { + "message": "Netzwerkgebühr" + }, + "feeRate": { + "message": "Gebührenrate" + }, + "networkFeeTooltip": { + "message": "Die gesamte Netzwerkgebühr" + }, + "total": { + "message": "Gesamt" + }, + "send": { + "message": "Senden" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Wird gesendet" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Empfängeradresse eingeben" + }, + "review": { + "message": "Überprüfen" + }, + "error": { + "message": "Fehler" + }, + "unknownError": { + "message": "Ein unbekannter Fehler ist aufgetreten" + }, + "feeTooLow": { + "message": "Gebühr zu niedrig" + }, + "feeRateTooLow": { + "message": "Gebührenrate zu niedrig" + }, + "noUtxosSelected": { + "message": "Transaktion konnte nicht erstellt werden: fehlende UTXOs" + }, + "outputBelowDustLimit": { + "message": "Betrag unter Dust-Limit" + }, + "insufficientFunds": { + "message": "Unzureichende Mittel, um Betrag plus Gebühr zu decken" + }, + "noRecipients": { + "message": "Fehlende Empfänger" + }, + "psbt": { + "message": "Ungültiges PSBT" + }, + "unknownUtxo": { + "message": "Transaktion konnte nicht erstellt werden: unbekannter UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Transaktion konnte nicht erstellt werden: fehlender Non-Witness UTXO" + }, + "base58": { + "message": "Ungültige Bitcoin-Adresse" + }, + "bech32": { + "message": "Ungültige Bitcoin-Adresse" + }, + "witnessVersion": { + "message": "Ungültige Bitcoin-Adresse: Witness-Version" + }, + "witnessProgram": { + "message": "Ungültige Bitcoin-Adresse: Witness-Programm" + }, + "legacyAddressTooLong": { + "message": "Ungültige Bitcoin-Adresse" + }, + "invalidBase58PayloadLength": { + "message": "Ungültige Bitcoin-Adresse: ungültige Länge" + }, + "invalidLegacyPrefix": { + "message": "Ungültige Bitcoin-Adresse: ungültiges Legacy-Prefix" + }, + "networkValidation": { + "message": "Ungültige Bitcoin-Adresse: falsches Netzwerk" + }, + "outOfRange": { + "message": "Ungültiger Betrag: außerhalb des Bereichs" + }, + "tooPrecise": { + "message": "Ungültiger Betrag: zu präzise" + }, + "missingDigits": { + "message": "Ungültiger Betrag: fehlende Ziffern" + }, + "inputTooLarge": { + "message": "Ungültiger Betrag: zu groß" + }, + "invalidCharacter": { + "message": "Ungültiger Betrag: ungültiges Zeichen" + }, + "unexpected": { + "message": "Ein unerwarteter Fehler ist aufgetreten" + }, + "error.0": { + "message": "Ungültiges Format" + }, + "error.1000": { + "message": "Validierung fehlgeschlagen" + }, + "error.2000": { + "message": "Ressource nicht gefunden" + }, + "error.3000": { + "message": "Verbindungsfehler" + }, + "error.4000": { + "message": "Wallet-Zustand beschädigt" + }, + "error.5000": { + "message": "Speicherfehler" + }, + "error.6000": { + "message": "Methode nicht implementiert oder nicht unterstützt" + }, + "error.7000": { + "message": "Zugriff verweigert oder unzureichende Berechtigung" + }, + "error.8000": { + "message": "Benutzeraktionsfehler" + }, + "error.9000": { + "message": "Beschädigter Zustand, Invariante nicht eingehalten oder fehlgeschlagene Assertion" + }, + "error.internal": { + "message": "Interner Fehler. Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support" + }, + "confirmation.origin": { + "message": "Ursprung" + }, + "confirmation.origin.tooltip": { + "message": "Die dApp, die die Signatur anfordert" + }, + "confirmation.account": { + "message": "Konto" + }, + "confirmation.signMessage.confirmButton": { + "message": "Signieren" + }, + "confirmation.signMessage.title": { + "message": "Nachricht signieren" + }, + "confirmation.signMessage.message": { + "message": "Nachricht" + }, + "confirmation.estimatedChanges": { + "message": "Geschätzte Änderungen" + }, + "confirmation.estimatedChanges.send": { + "message": "Sie senden" + }, + "confirmation.confirmButton": { + "message": "Bestätigen" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaktionsanfrage" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Anfrage von" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/el.json b/merged-packages/bitcoin-wallet-snap/locales/el.json new file mode 100644 index 00000000..0eff56d1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/el.json @@ -0,0 +1,221 @@ +{ + "locale": "el", + "messages": { + "reviewTransactionWarning": { + "message": "Ελέγξτε τη συναλλαγή πριν προχωρήσετε" + }, + "from": { + "message": "Από" + }, + "toAddress": { + "message": "Προς" + }, + "continue": { + "message": "Συνέχεια" + }, + "cancel": { + "message": "Ακύρωση" + }, + "clear": { + "message": "Καθαρισμός" + }, + "amount": { + "message": "Ποσό" + }, + "balance": { + "message": "Υπόλοιπο" + }, + "recipient": { + "message": "Παραλήπτης" + }, + "network": { + "message": "Δίκτυο" + }, + "minutes": { + "message": "λεπτά" + }, + "transactionSpeed": { + "message": "Ταχύτητα συναλλαγής" + }, + "transactionSpeedTooltip": { + "message": "Ο εκτιμώμενος χρόνος της συναλλαγής" + }, + "networkFee": { + "message": "Χρέωση δικτύου" + }, + "feeRate": { + "message": "Ρυθμός χρέωσης" + }, + "networkFeeTooltip": { + "message": "Η συνολική χρέωση δικτύου" + }, + "total": { + "message": "Σύνολο" + }, + "send": { + "message": "Αποστολή" + }, + "asset": { + "message": "Περιουσιακό στοιχείο" + }, + "sending": { + "message": "Αποστολή" + }, + "max": { + "message": "Μέγ" + }, + "recipientPlaceholder": { + "message": "Εισαγάγετε διεύθυνση παραλήπτη" + }, + "review": { + "message": "Επανεξέταση" + }, + "error": { + "message": "Σφάλμα" + }, + "unknownError": { + "message": "Προέκυψε ένα άγνωστο σφάλμα" + }, + "feeTooLow": { + "message": "Χρέωση πολύ χαμηλή" + }, + "feeRateTooLow": { + "message": "Ρυθμός χρέωσης πολύ χαμηλός" + }, + "noUtxosSelected": { + "message": "Αποτυχία κατασκευής συναλλαγής: λείπουν UTXOs" + }, + "outputBelowDustLimit": { + "message": "Ποσό κάτω από το όριο dust" + }, + "insufficientFunds": { + "message": "Ανεπαρκή κεφάλαια για κάλυψη ποσού συν χρέωση" + }, + "noRecipients": { + "message": "Λείπουν παραλήπτες" + }, + "psbt": { + "message": "Άκυρο PSBT" + }, + "unknownUtxo": { + "message": "Αποτυχία κατασκευής συναλλαγής: άγνωστο UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Αποτυχία κατασκευής συναλλαγής: λείπει non-witness UTXO" + }, + "base58": { + "message": "Άκυρη διεύθυνση Bitcoin" + }, + "bech32": { + "message": "Άκυρη διεύθυνση Bitcoin" + }, + "witnessVersion": { + "message": "Άκυρη διεύθυνση Bitcoin: έκδοση witness" + }, + "witnessProgram": { + "message": "Άκυρη διεύθυνση Bitcoin: πρόγραμμα witness" + }, + "legacyAddressTooLong": { + "message": "Άκυρη διεύθυνση Bitcoin" + }, + "invalidBase58PayloadLength": { + "message": "Άκυρη διεύθυνση Bitcoin: άκυρο μήκος" + }, + "invalidLegacyPrefix": { + "message": "Άκυρη διεύθυνση Bitcoin: άκυρο legacy prefix" + }, + "networkValidation": { + "message": "Άκυρη διεύθυνση Bitcoin: λάθος δίκτυο" + }, + "outOfRange": { + "message": "Άκυρο ποσό: εκτός εμβέλειας" + }, + "tooPrecise": { + "message": "Άκυρο ποσό: πολύ ακριβές" + }, + "missingDigits": { + "message": "Άκυρο ποσό: λείπουν ψηφία" + }, + "inputTooLarge": { + "message": "Άκυρο ποσό: πολύ μεγάλο" + }, + "invalidCharacter": { + "message": "Άκυρο ποσό: άκυρος χαρακτήρας" + }, + "unexpected": { + "message": "Προέκυψε ένα απροσδόκητο σφάλμα" + }, + "error.0": { + "message": "Άκυρη μορφή" + }, + "error.1000": { + "message": "Η επικύρωση απέτυχε" + }, + "error.2000": { + "message": "Ο πόρος δεν βρέθηκε" + }, + "error.3000": { + "message": "Σφάλμα σύνδεσης" + }, + "error.4000": { + "message": "Η κατάσταση πορτοφολιού είναι κατεστραμμένη" + }, + "error.5000": { + "message": "Σφάλμα αποθήκευσης" + }, + "error.6000": { + "message": "Η μέθοδος δεν έχει υλοποιηθεί ή δεν υποστηρίζεται" + }, + "error.7000": { + "message": "Άρνηση άδειας ή ανεπαρκής εξουσιοδότηση" + }, + "error.8000": { + "message": "Σφάλμα ενέργειας χρήστη" + }, + "error.9000": { + "message": "Κατεστραμμένη κατάσταση, η αμετάβλητη δεν τηρήθηκε ή απέτυχε η επιβεβαίωση." + }, + "error.internal": { + "message": "Εσωτερικό σφάλμα. Παρακαλώ δοκιμάστε ξανά αργότερα ή επικοινωνήστε με την υποστήριξη" + }, + "confirmation.origin": { + "message": "Προέλευση" + }, + "confirmation.origin.tooltip": { + "message": "Η dApp που ζητά την υπογραφή" + }, + "confirmation.account": { + "message": "Λογαριασμός" + }, + "confirmation.signMessage.confirmButton": { + "message": "Υπογραφή" + }, + "confirmation.signMessage.title": { + "message": "Υπογραφή μηνύματος" + }, + "confirmation.signMessage.message": { + "message": "Μήνυμα" + }, + "confirmation.estimatedChanges": { + "message": "Εκτιμώμενες αλλαγές" + }, + "confirmation.estimatedChanges.send": { + "message": "Θα στείλετε" + }, + "confirmation.confirmButton": { + "message": "Επιβεβαίωση" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Αίτημα συναλλαγής" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Ζητήθηκε από" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json new file mode 100644 index 00000000..5c8c69f1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -0,0 +1,266 @@ +{ + "locale": "en", + "messages": { + "reviewTransactionWarning": { + "message": "Review the transaction before proceeding" + }, + "from": { + "message": "From" + }, + "toAddress": { + "message": "To" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Cancel" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Amount" + }, + "balance": { + "message": "Balance" + }, + "recipient": { + "message": "Recipient" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaction speed" + }, + "transactionSpeedTooltip": { + "message": "The estimated time of the transaction" + }, + "networkFee": { + "message": "Network fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Send" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Sending" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Review" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.0": { + "message": "Invalid format" + }, + "error.1000": { + "message": "Validation failed" + }, + "error.2000": { + "message": "Resource not found" + }, + "error.3000": { + "message": "Connection error" + }, + "error.3100": { + "message": "One or more accounts failed to synchronize" + }, + "error.4000": { + "message": "Wallet state corrupted" + }, + "error.5000": { + "message": "Storage error" + }, + "error.6000": { + "message": "Method not implemented or not supported" + }, + "error.7000": { + "message": "Permission denied or insufficient authorization" + }, + "error.8000": { + "message": "User action error" + }, + "error.9000": { + "message": "Corrupted state, invariant not respected or failed assertion." + }, + "error.internal": { + "message": "Internal error. Please try again later or contact support" + }, + "confirmation.origin": { + "message": "Origin" + }, + "confirmation.origin.tooltip": { + "message": "The dApp requesting the signature" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Sign" + }, + "confirmation.signMessage.title": { + "message": "Sign Message" + }, + "confirmation.signMessage.message": { + "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Estimated changes" + }, + "confirmation.estimatedChanges.send": { + "message": "You send" + }, + "confirmation.confirmButton": { + "message": "Confirm" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaction request" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/es.json b/merged-packages/bitcoin-wallet-snap/locales/es.json new file mode 100644 index 00000000..371cda0d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/es.json @@ -0,0 +1,221 @@ +{ + "locale": "es", + "messages": { + "reviewTransactionWarning": { + "message": "Revise la transacción antes de continuar" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "A" + }, + "continue": { + "message": "Continuar" + }, + "cancel": { + "message": "Cancelar" + }, + "clear": { + "message": "Borrar" + }, + "amount": { + "message": "Monto" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatario" + }, + "network": { + "message": "Red" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidad de la transacción" + }, + "transactionSpeedTooltip": { + "message": "El tiempo estimado de la transacción" + }, + "networkFee": { + "message": "Tarifa de red" + }, + "feeRate": { + "message": "Tasa de tarifa" + }, + "networkFeeTooltip": { + "message": "La tarifa total de red" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "asset": { + "message": "Activo" + }, + "sending": { + "message": "Enviando" + }, + "max": { + "message": "Máx" + }, + "recipientPlaceholder": { + "message": "Ingrese la dirección de recepción" + }, + "review": { + "message": "Revisar" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "Se produjo un error desconocido" + }, + "feeTooLow": { + "message": "Tarifa demasiado baja" + }, + "feeRateTooLow": { + "message": "Tasa de tarifa demasiado baja" + }, + "noUtxosSelected": { + "message": "No se pudo construir la transacción: faltan UTXOs" + }, + "outputBelowDustLimit": { + "message": "Monto por debajo del límite de dust" + }, + "insufficientFunds": { + "message": "Fondos insuficientes para cubrir el monto más la tarifa" + }, + "noRecipients": { + "message": "Faltan destinatarios" + }, + "psbt": { + "message": "PSBT inválido" + }, + "unknownUtxo": { + "message": "No se pudo construir la transacción: UTXO desconocido" + }, + "MmssingNonWitnessUtxo": { + "message": "No se pudo construir la transacción: falta UTXO non-witness" + }, + "base58": { + "message": "Dirección de Bitcoin inválida" + }, + "bech32": { + "message": "Dirección de Bitcoin inválida" + }, + "witnessVersion": { + "message": "Dirección de Bitcoin inválida: versión witness" + }, + "witnessProgram": { + "message": "Dirección de Bitcoin inválida: programa witness" + }, + "legacyAddressTooLong": { + "message": "Dirección de Bitcoin inválida" + }, + "invalidBase58PayloadLength": { + "message": "Dirección de Bitcoin inválida: longitud inválida" + }, + "invalidLegacyPrefix": { + "message": "Dirección de Bitcoin inválida: prefijo legacy inválido" + }, + "networkValidation": { + "message": "Dirección de Bitcoin inválida: red incorrecta" + }, + "outOfRange": { + "message": "Monto inválido: fuera de rango" + }, + "tooPrecise": { + "message": "Monto inválido: demasiado preciso" + }, + "missingDigits": { + "message": "Monto inválido: faltan dígitos" + }, + "inputTooLarge": { + "message": "Monto inválido: demasiado grande" + }, + "invalidCharacter": { + "message": "Monto inválido: carácter inválido" + }, + "unexpected": { + "message": "Se produjo un error inesperado" + }, + "error.0": { + "message": "Formato inválido" + }, + "error.1000": { + "message": "Validación fallida" + }, + "error.2000": { + "message": "Recurso no encontrado" + }, + "error.3000": { + "message": "Error de conexión" + }, + "error.4000": { + "message": "Estado de la billetera corrompido" + }, + "error.5000": { + "message": "Error de almacenamiento" + }, + "error.6000": { + "message": "Método no implementado o no soportado" + }, + "error.7000": { + "message": "Permiso denegado o autorización insuficiente" + }, + "error.8000": { + "message": "Error de acción del usuario" + }, + "error.9000": { + "message": "Estado corrompido, invariante no respetada o aserción fallida." + }, + "error.internal": { + "message": "Error interno. Por favor intente de nuevo más tarde o contacte al soporte" + }, + "confirmation.origin": { + "message": "Origen" + }, + "confirmation.origin.tooltip": { + "message": "La dApp solicitando la firma" + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Firmar" + }, + "confirmation.signMessage.title": { + "message": "Firmar mensaje" + }, + "confirmation.signMessage.message": { + "message": "Mensaje" + }, + "confirmation.estimatedChanges": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Solicitud de" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/es_419.json b/merged-packages/bitcoin-wallet-snap/locales/es_419.json new file mode 100644 index 00000000..e3c29cbb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/es_419.json @@ -0,0 +1,221 @@ +{ + "locale": "es_419", + "messages": { + "reviewTransactionWarning": { + "message": "Revise la transacción antes de continuar" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "A" + }, + "continue": { + "message": "Continuar" + }, + "cancel": { + "message": "Cancelar" + }, + "clear": { + "message": "Borrar" + }, + "amount": { + "message": "Monto" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatario" + }, + "network": { + "message": "Red" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidad de la transacción" + }, + "transactionSpeedTooltip": { + "message": "El tiempo estimado de la transacción" + }, + "networkFee": { + "message": "Tarifa de red" + }, + "feeRate": { + "message": "Tasa de tarifa" + }, + "networkFeeTooltip": { + "message": "La tarifa total de red" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "asset": { + "message": "Activo" + }, + "sending": { + "message": "Enviando" + }, + "max": { + "message": "Máx" + }, + "recipientPlaceholder": { + "message": "Ingrese la dirección de recepción" + }, + "review": { + "message": "Revisar" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "Se produjo un error desconocido" + }, + "feeTooLow": { + "message": "Tarifa demasiado baja" + }, + "feeRateTooLow": { + "message": "Tasa de tarifa demasiado baja" + }, + "noUtxosSelected": { + "message": "No se pudo construir la transacción: faltan UTXOs" + }, + "outputBelowDustLimit": { + "message": "Monto por debajo del límite de dust" + }, + "insufficientFunds": { + "message": "Fondos insuficientes para cubrir el monto más la tarifa" + }, + "noRecipients": { + "message": "Faltan destinatarios" + }, + "psbt": { + "message": "PSBT inválido" + }, + "unknownUtxo": { + "message": "No se pudo construir la transacción: UTXO desconocido" + }, + "MmssingNonWitnessUtxo": { + "message": "No se pudo construir la transacción: falta UTXO non-witness" + }, + "base58": { + "message": "Dirección de Bitcoin inválida" + }, + "bech32": { + "message": "Dirección de Bitcoin inválida" + }, + "witnessVersion": { + "message": "Dirección de Bitcoin inválida: versión witness" + }, + "witnessProgram": { + "message": "Dirección de Bitcoin inválida: programa witness" + }, + "legacyAddressTooLong": { + "message": "Dirección de Bitcoin inválida" + }, + "invalidBase58PayloadLength": { + "message": "Dirección de Bitcoin inválida: longitud inválida" + }, + "invalidLegacyPrefix": { + "message": "Dirección de Bitcoin inválida: prefijo legacy inválido" + }, + "networkValidation": { + "message": "Dirección de Bitcoin inválida: red incorrecta" + }, + "outOfRange": { + "message": "Monto inválido: fuera de rango" + }, + "tooPrecise": { + "message": "Monto inválido: demasiado preciso" + }, + "missingDigits": { + "message": "Monto inválido: faltan dígitos" + }, + "inputTooLarge": { + "message": "Monto inválido: demasiado grande" + }, + "invalidCharacter": { + "message": "Monto inválido: carácter inválido" + }, + "unexpected": { + "message": "Se produjo un error inesperado" + }, + "error.0": { + "message": "Formato inválido" + }, + "error.1000": { + "message": "Validación fallida" + }, + "error.2000": { + "message": "Recurso no encontrado" + }, + "error.3000": { + "message": "Error de conexión" + }, + "error.4000": { + "message": "Estado de la billetera corrompido" + }, + "error.5000": { + "message": "Error de almacenamiento" + }, + "error.6000": { + "message": "Método no implementado o no soportado" + }, + "error.7000": { + "message": "Permiso denegado o autorización insuficiente" + }, + "error.8000": { + "message": "Error de acción del usuario" + }, + "error.9000": { + "message": "Estado corrompido, invariante no respetada o aserción fallida." + }, + "error.internal": { + "message": "Error interno. Por favor intente de nuevo más tarde o contacte al soporte" + }, + "confirmation.origin": { + "message": "Origen" + }, + "confirmation.origin.tooltip": { + "message": "La dApp solicitando la firma" + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Firmar" + }, + "confirmation.signMessage.title": { + "message": "Firmar mensaje" + }, + "confirmation.signMessage.message": { + "message": "Mensaje" + }, + "confirmation.estimatedChanges": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Solicitud de" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr.json b/merged-packages/bitcoin-wallet-snap/locales/fr.json new file mode 100644 index 00000000..23950195 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/fr.json @@ -0,0 +1,221 @@ +{ + "locale": "fr", + "messages": { + "reviewTransactionWarning": { + "message": "Vérifiez la transaction avant de continuer" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "À" + }, + "continue": { + "message": "Continuer" + }, + "cancel": { + "message": "Annuler" + }, + "clear": { + "message": "Effacer" + }, + "amount": { + "message": "Montant" + }, + "balance": { + "message": "Solde" + }, + "recipient": { + "message": "Destinataire" + }, + "network": { + "message": "Réseau" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Vitesse de transaction" + }, + "transactionSpeedTooltip": { + "message": "Le temps estimé de la transaction" + }, + "networkFee": { + "message": "Frais de réseau" + }, + "feeRate": { + "message": "Taux de frais" + }, + "networkFeeTooltip": { + "message": "Les frais de réseau totaux" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Envoyer" + }, + "asset": { + "message": "Actif" + }, + "sending": { + "message": "Envoi" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Entrez l'adresse de réception" + }, + "review": { + "message": "Vérifier" + }, + "error": { + "message": "Erreur" + }, + "unknownError": { + "message": "Une erreur inconnue s'est produite" + }, + "feeTooLow": { + "message": "Frais trop bas" + }, + "feeRateTooLow": { + "message": "Taux de frais trop bas" + }, + "noUtxosSelected": { + "message": "Échec de la construction de la transaction : UTXOs manquants" + }, + "outputBelowDustLimit": { + "message": "Montant en dessous de la limite dust" + }, + "insufficientFunds": { + "message": "Fonds insuffisants pour couvrir le montant plus les frais" + }, + "noRecipients": { + "message": "Destinataires manquants" + }, + "psbt": { + "message": "PSBT invalide" + }, + "unknownUtxo": { + "message": "Échec de la construction de la transaction : UTXO inconnu" + }, + "MmssingNonWitnessUtxo": { + "message": "Échec de la construction de la transaction : UTXO non-witness manquant" + }, + "base58": { + "message": "Adresse Bitcoin invalide" + }, + "bech32": { + "message": "Adresse Bitcoin invalide" + }, + "witnessVersion": { + "message": "Adresse Bitcoin invalide : version witness" + }, + "witnessProgram": { + "message": "Adresse Bitcoin invalide : programme witness" + }, + "legacyAddressTooLong": { + "message": "Adresse Bitcoin invalide" + }, + "invalidBase58PayloadLength": { + "message": "Adresse Bitcoin invalide : longueur invalide" + }, + "invalidLegacyPrefix": { + "message": "Adresse Bitcoin invalide : préfixe legacy invalide" + }, + "networkValidation": { + "message": "Adresse Bitcoin invalide : réseau incorrect" + }, + "outOfRange": { + "message": "Montant invalide : hors plage" + }, + "tooPrecise": { + "message": "Montant invalide : trop précis" + }, + "missingDigits": { + "message": "Montant invalide : chiffres manquants" + }, + "inputTooLarge": { + "message": "Montant invalide : trop grand" + }, + "invalidCharacter": { + "message": "Montant invalide : caractère invalide" + }, + "unexpected": { + "message": "Une erreur inattendue s'est produite" + }, + "error.0": { + "message": "Format invalide" + }, + "error.1000": { + "message": "Validation échouée" + }, + "error.2000": { + "message": "Ressource non trouvée" + }, + "error.3000": { + "message": "Erreur de connexion" + }, + "error.4000": { + "message": "État du portefeuille corrompu" + }, + "error.5000": { + "message": "Erreur de stockage" + }, + "error.6000": { + "message": "Méthode non implémentée ou non supportée" + }, + "error.7000": { + "message": "Permission refusée ou autorisation insuffisante" + }, + "error.8000": { + "message": "Erreur d'action de l'utilisateur" + }, + "error.9000": { + "message": "État corrompu, invariante non respectée ou assertion échouée." + }, + "error.internal": { + "message": "Erreur interne. Veuillez réessayer plus tard ou contacter le support" + }, + "confirmation.origin": { + "message": "Origine" + }, + "confirmation.origin.tooltip": { + "message": "Le dApp demandant la signature" + }, + "confirmation.account": { + "message": "Compte" + }, + "confirmation.signMessage.confirmButton": { + "message": "Signer" + }, + "confirmation.signMessage.title": { + "message": "Signer le message" + }, + "confirmation.signMessage.message": { + "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Changements estimés" + }, + "confirmation.estimatedChanges.send": { + "message": "Vous envoyez" + }, + "confirmation.confirmButton": { + "message": "Confirmer" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Demande de transaction" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Demande de la part de" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi.json b/merged-packages/bitcoin-wallet-snap/locales/hi.json new file mode 100644 index 00000000..f5ef7c5a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/hi.json @@ -0,0 +1,221 @@ +{ + "locale": "hi", + "messages": { + "reviewTransactionWarning": { + "message": "आगे बढ़ने से पहले ट्रांसेक्शन की समीक्षा करें" + }, + "from": { + "message": "से" + }, + "toAddress": { + "message": "को" + }, + "continue": { + "message": "जारी रखें" + }, + "cancel": { + "message": "रद्द करें" + }, + "clear": { + "message": "साफ़ करें" + }, + "amount": { + "message": "राशि" + }, + "balance": { + "message": "शेष राशि" + }, + "recipient": { + "message": "प्राप्तकर्ता" + }, + "network": { + "message": "नेटवर्क" + }, + "minutes": { + "message": "मिनट" + }, + "transactionSpeed": { + "message": "ट्रांसेक्शन की गति" + }, + "transactionSpeedTooltip": { + "message": "ट्रांसेक्शन का अनुमानित समय" + }, + "networkFee": { + "message": "नेटवर्क शुल्क" + }, + "feeRate": { + "message": "शुल्क दर" + }, + "networkFeeTooltip": { + "message": "कुल नेटवर्क शुल्क" + }, + "total": { + "message": "कुल" + }, + "send": { + "message": "भेजें" + }, + "asset": { + "message": "एसेट" + }, + "sending": { + "message": "भेजा जा रहा है" + }, + "max": { + "message": "अधिकतम" + }, + "recipientPlaceholder": { + "message": "प्राप्त करने वाला पता दर्ज करें" + }, + "review": { + "message": "समीक्षा करें" + }, + "error": { + "message": "त्रुटि" + }, + "unknownError": { + "message": "एक अज्ञात त्रुटि हुई" + }, + "feeTooLow": { + "message": "शुल्क बहुत कम है" + }, + "feeRateTooLow": { + "message": "शुल्क दर बहुत कम है" + }, + "noUtxosSelected": { + "message": "ट्रांसेक्शन बनाने में विफल: UTXOs गायब हैं" + }, + "outputBelowDustLimit": { + "message": "राशि डस्ट लिमिट से कम है" + }, + "insufficientFunds": { + "message": "राशि और शुल्क कवर करने के लिए अपर्याप्त फंड" + }, + "noRecipients": { + "message": "प्राप्तकर्ता गायब हैं" + }, + "psbt": { + "message": "अमान्य PSBT" + }, + "unknownUtxo": { + "message": "ट्रांसेक्शन बनाने में विफल: अज्ञात UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "ट्रांसेक्शन बनाने में विफल: गैर-गवाह UTXO गायब" + }, + "base58": { + "message": "अमान्य बिटकॉइन पता" + }, + "bech32": { + "message": "अमान्य बिटकॉइन पता" + }, + "witnessVersion": { + "message": "अमान्य बिटकॉइन पता: गवाह संस्करण" + }, + "witnessProgram": { + "message": "अमान्य बिटकॉइन पता: गवाह कार्यक्रम" + }, + "legacyAddressTooLong": { + "message": "अमान्य बिटकॉइन पता" + }, + "invalidBase58PayloadLength": { + "message": "अमान्य बिटकॉइन पता: अमान्य लंबाई" + }, + "invalidLegacyPrefix": { + "message": "अमान्य बिटकॉइन पता: अमान्य लिगेसी उपसर्ग" + }, + "networkValidation": { + "message": "अमान्य बिटकॉइन पता: गलत नेटवर्क" + }, + "outOfRange": { + "message": "अमान्य राशि: रेंज के बाहर" + }, + "tooPrecise": { + "message": "अमान्य राशि: बहुत सटीक" + }, + "missingDigits": { + "message": "अमान्य राशि: अंक गायब" + }, + "inputTooLarge": { + "message": "अमान्य राशि: बहुत बड़ा" + }, + "invalidCharacter": { + "message": "अमान्य राशि: अमान्य अक्षर" + }, + "unexpected": { + "message": "एक अप्रत्याशित त्रुटि हुई" + }, + "error.0": { + "message": "अमान्य प्रारूप" + }, + "error.1000": { + "message": "मान्यकरण विफल" + }, + "error.2000": { + "message": "संसाधन नहीं मिला" + }, + "error.3000": { + "message": "कनेक्शन त्रुटि" + }, + "error.4000": { + "message": "वॉलेट स्थिति दूषित" + }, + "error.5000": { + "message": "भंडारण त्रुटि" + }, + "error.6000": { + "message": "विधि लागू नहीं या समर्थित नहीं" + }, + "error.7000": { + "message": "अनुमति अस्वीकृत या अपर्याप्त प्राधिकरण" + }, + "error.8000": { + "message": "उपयोगकर्ता क्रिया त्रुटि" + }, + "error.9000": { + "message": "दूषित स्थिति, अपरिवर्तनीय नहीं माना गया या असफल दावा." + }, + "error.internal": { + "message": "आंतरिक त्रुटि. कृपया बाद में प्रयास करें या समर्थन से संपर्क करें" + }, + "confirmation.origin": { + "message": "उत्पत्ति" + }, + "confirmation.origin.tooltip": { + "message": "हस्ताक्षर का अनुरोध करने वाला dApp" + }, + "confirmation.account": { + "message": "खाता" + }, + "confirmation.signMessage.confirmButton": { + "message": "हस्ताक्षर" + }, + "confirmation.signMessage.title": { + "message": "संदेश पर हस्ताक्षर करें" + }, + "confirmation.signMessage.message": { + "message": "संदेश" + }, + "confirmation.estimatedChanges": { + "message": "अनुमानित बदलाव" + }, + "confirmation.estimatedChanges.send": { + "message": "आप भेजते हैं" + }, + "confirmation.confirmButton": { + "message": "कन्फर्म करें" + }, + "confirmation.signAndSendTransaction.title": { + "message": "ट्रांसेक्शन रिक्वेस्ट" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "इनसे मिला अनुरोध" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/id.json b/merged-packages/bitcoin-wallet-snap/locales/id.json new file mode 100644 index 00000000..4c4b9354 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/id.json @@ -0,0 +1,221 @@ +{ + "locale": "id", + "messages": { + "reviewTransactionWarning": { + "message": "Tinjau transaksi sebelum melanjutkan" + }, + "from": { + "message": "Dari" + }, + "toAddress": { + "message": "Ke" + }, + "continue": { + "message": "Lanjutkan" + }, + "cancel": { + "message": "Batal" + }, + "clear": { + "message": "Hapus" + }, + "amount": { + "message": "Jumlah" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Penerima" + }, + "network": { + "message": "Jaringan" + }, + "minutes": { + "message": "menit" + }, + "transactionSpeed": { + "message": "Kecepatan transaksi" + }, + "transactionSpeedTooltip": { + "message": "Estimasi waktu transaksi" + }, + "networkFee": { + "message": "Biaya jaringan" + }, + "feeRate": { + "message": "Tingkat biaya" + }, + "networkFeeTooltip": { + "message": "Total biaya jaringan" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Kirim" + }, + "asset": { + "message": "Aset" + }, + "sending": { + "message": "Mengirim" + }, + "max": { + "message": "Maks" + }, + "recipientPlaceholder": { + "message": "Masukkan alamat penerima" + }, + "review": { + "message": "Tinjau" + }, + "error": { + "message": "Kesalahan" + }, + "unknownError": { + "message": "Terjadi kesalahan yang tidak diketahui" + }, + "feeTooLow": { + "message": "Biaya terlalu rendah" + }, + "feeRateTooLow": { + "message": "Tingkat biaya terlalu rendah" + }, + "noUtxosSelected": { + "message": "Gagal membangun transaksi: UTXOs hilang" + }, + "outputBelowDustLimit": { + "message": "Jumlah di bawah batas dust" + }, + "insufficientFunds": { + "message": "Dana tidak cukup untuk menutupi jumlah plus biaya" + }, + "noRecipients": { + "message": "Penerima hilang" + }, + "psbt": { + "message": "PSBT tidak valid" + }, + "unknownUtxo": { + "message": "Gagal membangun transaksi: UTXO tidak dikenal" + }, + "MmssingNonWitnessUtxo": { + "message": "Gagal membangun transaksi: UTXO non-witness hilang" + }, + "base58": { + "message": "Alamat Bitcoin tidak valid" + }, + "bech32": { + "message": "Alamat Bitcoin tidak valid" + }, + "witnessVersion": { + "message": "Alamat Bitcoin tidak valid: versi witness" + }, + "witnessProgram": { + "message": "Alamat Bitcoin tidak valid: program witness" + }, + "legacyAddressTooLong": { + "message": "Alamat Bitcoin tidak valid" + }, + "invalidBase58PayloadLength": { + "message": "Alamat Bitcoin tidak valid: panjang tidak valid" + }, + "invalidLegacyPrefix": { + "message": "Alamat Bitcoin tidak valid: prefix legacy tidak valid" + }, + "networkValidation": { + "message": "Alamat Bitcoin tidak valid: jaringan salah" + }, + "outOfRange": { + "message": "Jumlah tidak valid: di luar jangkauan" + }, + "tooPrecise": { + "message": "Jumlah tidak valid: terlalu presisi" + }, + "missingDigits": { + "message": "Jumlah tidak valid: digit hilang" + }, + "inputTooLarge": { + "message": "Jumlah tidak valid: terlalu besar" + }, + "invalidCharacter": { + "message": "Jumlah tidak valid: karakter tidak valid" + }, + "unexpected": { + "message": "Terjadi kesalahan tak terduga" + }, + "error.0": { + "message": "Format tidak valid" + }, + "error.1000": { + "message": "Validasi gagal" + }, + "error.2000": { + "message": "Sumber daya tidak ditemukan" + }, + "error.3000": { + "message": "Kesalahan koneksi" + }, + "error.4000": { + "message": "Keadaan wallet rusak" + }, + "error.5000": { + "message": "Kesalahan penyimpanan" + }, + "error.6000": { + "message": "Metode tidak diimplementasikan atau tidak didukung" + }, + "error.7000": { + "message": "Izin ditolak atau otorisasi tidak cukup" + }, + "error.8000": { + "message": "Kesalahan tindakan pengguna" + }, + "error.9000": { + "message": "Keadaan rusak, invarianta tidak dihormati atau asersi gagal." + }, + "error.internal": { + "message": "Kesalahan internal. Silakan coba lagi nanti atau hubungi dukungan" + }, + "confirmation.origin": { + "message": "Asal" + }, + "confirmation.origin.tooltip": { + "message": "DApp yang meminta tanda tangan" + }, + "confirmation.account": { + "message": "Akun" + }, + "confirmation.signMessage.confirmButton": { + "message": "Tanda tangani" + }, + "confirmation.signMessage.title": { + "message": "Tanda tangani Pesan" + }, + "confirmation.signMessage.message": { + "message": "Pesan" + }, + "confirmation.estimatedChanges": { + "message": "Estimasi perubahan" + }, + "confirmation.estimatedChanges.send": { + "message": "Anda mengirim" + }, + "confirmation.confirmButton": { + "message": "Konfirmasikan" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Permintaan transaksi" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Permintaan dari" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja.json b/merged-packages/bitcoin-wallet-snap/locales/ja.json new file mode 100644 index 00000000..476db885 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ja.json @@ -0,0 +1,221 @@ +{ + "locale": "ja", + "messages": { + "reviewTransactionWarning": { + "message": "進める前にトランザクションを確認してください" + }, + "from": { + "message": "送金元" + }, + "toAddress": { + "message": "宛先" + }, + "continue": { + "message": "続ける" + }, + "cancel": { + "message": "キャンセル" + }, + "clear": { + "message": "クリア" + }, + "amount": { + "message": "金額" + }, + "balance": { + "message": "残高" + }, + "recipient": { + "message": "受取人" + }, + "network": { + "message": "ネットワーク" + }, + "minutes": { + "message": "分" + }, + "transactionSpeed": { + "message": "トランザクション速度" + }, + "transactionSpeedTooltip": { + "message": "トランザクションの予想時間" + }, + "networkFee": { + "message": "ネットワーク手数料" + }, + "feeRate": { + "message": "手数料率" + }, + "networkFeeTooltip": { + "message": "総ネットワーク手数料" + }, + "total": { + "message": "合計" + }, + "send": { + "message": "送信" + }, + "asset": { + "message": "資産" + }, + "sending": { + "message": "送信中" + }, + "max": { + "message": "最大" + }, + "recipientPlaceholder": { + "message": "受信アドレスを入力" + }, + "review": { + "message": "レビュー" + }, + "error": { + "message": "エラー" + }, + "unknownError": { + "message": "不明なエラーが発生しました" + }, + "feeTooLow": { + "message": "手数料が低すぎます" + }, + "feeRateTooLow": { + "message": "手数料率が低すぎます" + }, + "noUtxosSelected": { + "message": "トランザクションの構築に失敗: UTXO が不足しています" + }, + "outputBelowDustLimit": { + "message": "金額がダスト制限以下です" + }, + "insufficientFunds": { + "message": "金額プラス手数料をカバーするのに資金が不足しています" + }, + "noRecipients": { + "message": "受取人が不足しています" + }, + "psbt": { + "message": "無効な PSBT" + }, + "unknownUtxo": { + "message": "トランザクションの構築に失敗: 不明な UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "トランザクションの構築に失敗: non-witness UTXO が不足しています" + }, + "base58": { + "message": "無効な Bitcoin アドレス" + }, + "bech32": { + "message": "無効な Bitcoin アドレス" + }, + "witnessVersion": { + "message": "無効な Bitcoin アドレス: witness バージョン" + }, + "witnessProgram": { + "message": "無効な Bitcoin アドレス: witness プログラム" + }, + "legacyAddressTooLong": { + "message": "無効な Bitcoin アドレス" + }, + "invalidBase58PayloadLength": { + "message": "無効な Bitcoin アドレス: 無効な長さ" + }, + "invalidLegacyPrefix": { + "message": "無効な Bitcoin アドレス: 無効な legacy プレフィックス" + }, + "networkValidation": { + "message": "無効な Bitcoin アドレス: 間違ったネットワーク" + }, + "outOfRange": { + "message": "無効な金額: 範囲外" + }, + "tooPrecise": { + "message": "無効な金額: 精度が高すぎる" + }, + "missingDigits": { + "message": "無効な金額: 桁が不足" + }, + "inputTooLarge": { + "message": "無効な金額: 大きすぎる" + }, + "invalidCharacter": { + "message": "無効な金額: 無効な文字" + }, + "unexpected": { + "message": "予期せぬエラーが発生しました" + }, + "error.0": { + "message": "無効な形式" + }, + "error.1000": { + "message": "検証に失敗しました" + }, + "error.2000": { + "message": "リソースが見つかりません" + }, + "error.3000": { + "message": "接続エラー" + }, + "error.4000": { + "message": "ウォレット状態が破損しています" + }, + "error.5000": { + "message": "ストレージエラー" + }, + "error.6000": { + "message": "メソッドが実装されていないかサポートされていません" + }, + "error.7000": { + "message": "権限が拒否されたか十分な認証がありません" + }, + "error.8000": { + "message": "ユーザーアクションエラー" + }, + "error.9000": { + "message": "破損した状態、不変条件が守られていないかアサーションに失敗しました。" + }, + "error.internal": { + "message": "内部エラー。後ほどお試しくださいまたはサポートにご連絡ください" + }, + "confirmation.origin": { + "message": "オリジン" + }, + "confirmation.origin.tooltip": { + "message": "署名をリクエストしている dApp" + }, + "confirmation.account": { + "message": "アカウント" + }, + "confirmation.signMessage.confirmButton": { + "message": "署名" + }, + "confirmation.signMessage.title": { + "message": "メッセージに署名" + }, + "confirmation.signMessage.message": { + "message": "メッセージ" + }, + "confirmation.estimatedChanges": { + "message": "予測される増減額" + }, + "confirmation.estimatedChanges.send": { + "message": "送金額" + }, + "confirmation.confirmButton": { + "message": "確定" + }, + "confirmation.signAndSendTransaction.title": { + "message": "トランザクションリクエスト" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "要求元" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko.json b/merged-packages/bitcoin-wallet-snap/locales/ko.json new file mode 100644 index 00000000..3d3305e1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ko.json @@ -0,0 +1,191 @@ +{ + "locale": "ko", + "messages": { + "reviewTransactionWarning": { + "message": "계속하기 전에 트랜잭션을 검토하세요" + }, + "from": { + "message": "보내는 사람" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "취소" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "금액" + }, + "balance": { + "message": "잔액" + }, + "recipient": { + "message": "받는 사람" + }, + "network": { + "message": "네트워크" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "트랜잭션 속도" + }, + "transactionSpeedTooltip": { + "message": "예상 트랜잭션 시간" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "총액" + }, + "send": { + "message": "보내기" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "보내는 중" + }, + "max": { + "message": "최대" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "검토" + }, + "error": { + "message": "오류" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "내부 오류. 나중에 다시 시도하거나 지원팀에 문의하세요" + }, + "confirmation.origin": { + "message": "출처" + }, + "confirmation.origin.tooltip": { + "message": "서명을 요청하는 dApp" + }, + "confirmation.account": { + "message": "계정" + }, + "confirmation.signMessage.confirmButton": { + "message": "서명" + }, + "confirmation.signMessage.title": { + "message": "메시지 서명" + }, + "confirmation.signMessage.message": { + "message": "메시지" + }, + "confirmation.estimatedChanges": { + "message": "예상 변동 사항" + }, + "confirmation.estimatedChanges.send": { + "message": "전송:" + }, + "confirmation.confirmButton": { + "message": "컨펌" + }, + "confirmation.signAndSendTransaction.title": { + "message": "트랜잭션 요청" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "요청자:" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt.json b/merged-packages/bitcoin-wallet-snap/locales/pt.json new file mode 100644 index 00000000..38609f32 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/pt.json @@ -0,0 +1,191 @@ +{ + "locale": "pt-BR", + "messages": { + "reviewTransactionWarning": { + "message": "Revise a transação antes de prosseguir" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Cancelar" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Valor" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatário" + }, + "network": { + "message": "Rede" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidade da transação" + }, + "transactionSpeedTooltip": { + "message": "O tempo estimado da transação" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Enviando" + }, + "max": { + "message": "Máx." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Revisar" + }, + "error": { + "message": "Erro" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Erro interno. Por favor, tente novamente mais tarde ou contate o suporte" + }, + "confirmation.origin": { + "message": "Origem" + }, + "confirmation.origin.tooltip": { + "message": "O dApp solicitando a assinatura" + }, + "confirmation.account": { + "message": "Conta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Assinar" + }, + "confirmation.signMessage.title": { + "message": "Assinar Mensagem" + }, + "confirmation.signMessage.message": { + "message": "Mensagem" + }, + "confirmation.estimatedChanges": { + "message": "Alterações estimadas" + }, + "confirmation.estimatedChanges.send": { + "message": "Você envia" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitação de transação" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Solicitação de" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru.json b/merged-packages/bitcoin-wallet-snap/locales/ru.json new file mode 100644 index 00000000..e0be0afa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ru.json @@ -0,0 +1,191 @@ +{ + "locale": "ru", + "messages": { + "reviewTransactionWarning": { + "message": "Проверьте транзакцию, прежде чем продолжить" + }, + "from": { + "message": "От" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Отмена" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Сумма" + }, + "balance": { + "message": "Баланс" + }, + "recipient": { + "message": "Получатель" + }, + "network": { + "message": "Сеть" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Скорость транзакции" + }, + "transactionSpeedTooltip": { + "message": "Примерное время транзакции" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Итого" + }, + "send": { + "message": "Отправить" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Отправка..." + }, + "max": { + "message": "Макс." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Просмотр" + }, + "error": { + "message": "Ошибка" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Внутренняя ошибка. Пожалуйста, попробуйте позже или обратитесь в поддержку" + }, + "confirmation.origin": { + "message": "Источник" + }, + "confirmation.origin.tooltip": { + "message": "dApp, запрашивающий подпись" + }, + "confirmation.account": { + "message": "Аккаунт" + }, + "confirmation.signMessage.confirmButton": { + "message": "Подписать" + }, + "confirmation.signMessage.title": { + "message": "Подписать сообщение" + }, + "confirmation.signMessage.message": { + "message": "Сообщение" + }, + "confirmation.estimatedChanges": { + "message": "Прогнозируемые изменения" + }, + "confirmation.estimatedChanges.send": { + "message": "Вы отправляете" + }, + "confirmation.confirmButton": { + "message": "Подтвердить" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Запрос транзакции" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Запрос от" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl.json b/merged-packages/bitcoin-wallet-snap/locales/tl.json new file mode 100644 index 00000000..2d671764 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/tl.json @@ -0,0 +1,191 @@ +{ + "locale": "tl", + "messages": { + "reviewTransactionWarning": { + "message": "Suriin ang transaksyon bago magpatuloy" + }, + "from": { + "message": "Mula sa" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Kanselahin" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Halaga" + }, + "balance": { + "message": "Balanse" + }, + "recipient": { + "message": "Tatanggap" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Bilis ng Transaksyon" + }, + "transactionSpeedTooltip": { + "message": "Ang tinatayang tagal ng transaksyon" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Kabuuan" + }, + "send": { + "message": "Ipadala" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Ipinapadala" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Suriin" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Panloob na error. Subukang muli mamaya o makipag-ugnayan sa suporta" + }, + "confirmation.origin": { + "message": "Pinagmulan" + }, + "confirmation.origin.tooltip": { + "message": "Ang dApp na humihingi ng lagda" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Lagdaan" + }, + "confirmation.signMessage.title": { + "message": "Lagdaan ang Mensahe" + }, + "confirmation.signMessage.message": { + "message": "Mensahe" + }, + "confirmation.estimatedChanges": { + "message": "Tinatayang mga pagbabago" + }, + "confirmation.estimatedChanges.send": { + "message": "Nagpadala ka ng" + }, + "confirmation.confirmButton": { + "message": "Kumpirmahin" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Hiling na transaksyon" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Kahilingan mula sa/kay" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr.json b/merged-packages/bitcoin-wallet-snap/locales/tr.json new file mode 100644 index 00000000..4ec3aab3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/tr.json @@ -0,0 +1,191 @@ +{ + "locale": "tr", + "messages": { + "reviewTransactionWarning": { + "message": "Devam etmeden önce işlemi inceleyin" + }, + "from": { + "message": "Gönderen" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "İptal" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Miktar" + }, + "balance": { + "message": "Bakiye" + }, + "recipient": { + "message": "Alıcı" + }, + "network": { + "message": "Ağ" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "İşlem Hızı" + }, + "transactionSpeedTooltip": { + "message": "Tahmini işlem süresi" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Toplam" + }, + "send": { + "message": "Gönder" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Gönderiliyor" + }, + "max": { + "message": "Maksimum" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "İncele" + }, + "error": { + "message": "Hata" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "İç hata. Lütfen daha sonra tekrar deneyin veya destekle iletişime geçin" + }, + "confirmation.origin": { + "message": "Kaynak" + }, + "confirmation.origin.tooltip": { + "message": "İmzayı isteyen dApp" + }, + "confirmation.account": { + "message": "Hesap" + }, + "confirmation.signMessage.confirmButton": { + "message": "İmzala" + }, + "confirmation.signMessage.title": { + "message": "Mesajı İmzala" + }, + "confirmation.signMessage.message": { + "message": "Mesaj" + }, + "confirmation.estimatedChanges": { + "message": "Tahmini değişiklikler" + }, + "confirmation.estimatedChanges.send": { + "message": "Gönderdiğiniz" + }, + "confirmation.confirmButton": { + "message": "Onayla" + }, + "confirmation.signAndSendTransaction.title": { + "message": "İşlem talebi" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Talebi gönderen" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi.json b/merged-packages/bitcoin-wallet-snap/locales/vi.json new file mode 100644 index 00000000..a2e9a273 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/vi.json @@ -0,0 +1,191 @@ +{ + "locale": "vi", + "messages": { + "reviewTransactionWarning": { + "message": "Xem lại giao dịch trước khi tiếp tục" + }, + "from": { + "message": "Từ" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Hủy" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Số tiền" + }, + "balance": { + "message": "Số dư" + }, + "recipient": { + "message": "Người nhận" + }, + "network": { + "message": "Mạng" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Tốc độ giao dịch" + }, + "transactionSpeedTooltip": { + "message": "Thời gian giao dịch ước tính" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Tổng" + }, + "send": { + "message": "Gửi" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Đang gửi" + }, + "max": { + "message": "Tối đa" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Xem lại" + }, + "error": { + "message": "Lỗi" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Lỗi nội bộ. Vui lòng thử lại sau hoặc liên hệ hỗ trợ" + }, + "confirmation.origin": { + "message": "Nguồn gốc" + }, + "confirmation.origin.tooltip": { + "message": "DApp yêu cầu chữ ký" + }, + "confirmation.account": { + "message": "Tài khoản" + }, + "confirmation.signMessage.confirmButton": { + "message": "Ký" + }, + "confirmation.signMessage.title": { + "message": "Ký Tin nhắn" + }, + "confirmation.signMessage.message": { + "message": "Tin nhắn" + }, + "confirmation.estimatedChanges": { + "message": "Thay đổi ước tính" + }, + "confirmation.estimatedChanges.send": { + "message": "Bạn gửi" + }, + "confirmation.confirmButton": { + "message": "Xác nhận" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Yêu cầu giao dịch" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Yêu cầu từ" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json new file mode 100644 index 00000000..253957bd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json @@ -0,0 +1,191 @@ +{ + "locale": "中文", + "messages": { + "reviewTransactionWarning": { + "message": "继续之前,请先审查交易" + }, + "from": { + "message": "从" + }, + "toAddress": { + "message": "To Address" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "取消" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "金额" + }, + "balance": { + "message": "余额" + }, + "recipient": { + "message": "收款人" + }, + "network": { + "message": "网络" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "交易速度" + }, + "transactionSpeedTooltip": { + "message": "交易预计时间" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "总额" + }, + "send": { + "message": "发送" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "发送中" + }, + "max": { + "message": "最多" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "审查" + }, + "error": { + "message": "错误" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "内部错误。请稍后重试或联系支持" + }, + "confirmation.origin": { + "message": "来源" + }, + "confirmation.origin.tooltip": { + "message": "请求签名的 dApp" + }, + "confirmation.account": { + "message": "账户" + }, + "confirmation.signMessage.confirmButton": { + "message": "签名" + }, + "confirmation.signMessage.title": { + "message": "签名消息" + }, + "confirmation.signMessage.message": { + "message": "消息" + }, + "confirmation.estimatedChanges": { + "message": "预计变化" + }, + "confirmation.estimatedChanges.send": { + "message": "您发送" + }, + "confirmation.confirmButton": { + "message": "确认" + }, + "confirmation.signAndSendTransaction.title": { + "message": "交易请求" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "请求来自" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json new file mode 100644 index 00000000..5ae58ba5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -0,0 +1,263 @@ +{ + "reviewTransactionWarning": { + "message": "Review the transaction before proceeding" + }, + "from": { + "message": "From" + }, + "toAddress": { + "message": "To" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Cancel" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Amount" + }, + "balance": { + "message": "Balance" + }, + "recipient": { + "message": "Recipient" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaction speed" + }, + "transactionSpeedTooltip": { + "message": "The estimated time of the transaction" + }, + "networkFee": { + "message": "Network fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Send" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Sending" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Review" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + }, + "error.0": { + "message": "Invalid format" + }, + "error.1000": { + "message": "Validation failed" + }, + "error.2000": { + "message": "Resource not found" + }, + "error.3000": { + "message": "Connection error" + }, + "error.3100": { + "message": "One or more accounts failed to synchronize" + }, + "error.4000": { + "message": "Wallet state corrupted" + }, + "error.5000": { + "message": "Storage error" + }, + "error.6000": { + "message": "Method not implemented or not supported" + }, + "error.7000": { + "message": "Permission denied or insufficient authorization" + }, + "error.8000": { + "message": "User action error" + }, + "error.9000": { + "message": "Corrupted state, invariant not respected or failed assertion." + }, + "error.internal": { + "message": "Internal error. Please try again later or contact support" + }, + "confirmation.origin": { + "message": "Origin" + }, + "confirmation.origin.tooltip": { + "message": "The dApp requesting the signature" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Sign" + }, + "confirmation.signMessage.title": { + "message": "Sign Message" + }, + "confirmation.signMessage.message": { + "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Estimated changes" + }, + "confirmation.estimatedChanges.send": { + "message": "You send" + }, + "confirmation.confirmButton": { + "message": "Confirm" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaction request" + }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, + "confirmation.requestOrigin": { + "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" + } +} diff --git a/merged-packages/bitcoin-wallet-snap/openrpc.json b/merged-packages/bitcoin-wallet-snap/openrpc.json new file mode 100644 index 00000000..6dc00918 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/openrpc.json @@ -0,0 +1,515 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Bitcoin Snap Client RPC API", + "description": "RPC methods exposed via the onClientRequest handler in the Bitcoin Wallet Snap.", + "version": "1.0.0" + }, + "methods": [ + { + "name": "startSendTransactionFlow", + "description": "Initiates the send transaction flow for the specified account, potentially displaying UI and returning the transaction ID if successful.", + "paramStructure": "by-name", + "params": [ + { + "name": "account", + "description": "The account ID to use for the transaction.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network). Not used internally but validated.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + { + "name": "assetId", + "description": "The asset ID. Not used internally but validated.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "sendTransactionResponse", + "description": "The result of the send operation, or null if cancelled.", + "schema": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["transactionId", "canBeMalleable"], + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID." + }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." + } + } + } + ] + } + } + }, + { + "name": "signAndSendTransaction", + "description": "Signs and sends a partially signed Bitcoin transaction (PSBT) for the specified account.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "description": "The account ID to use for signing and sending.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network). Not used internally but validated.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + { + "name": "transaction", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "sendTransactionResponse", + "description": "The result of the send operation, or null if failed.", + "schema": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["transactionId", "canBeMalleable"], + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID." + }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." + } + } + } + ] + } + } + }, + { + "name": "computeFee", + "description": "Computes the fee for a partially signed Bitcoin transaction (PSBT) for the specified account.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "description": "The account ID to use for fee computation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "transaction", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network) for CAIP-19 asset identification.", + "required": true, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + } + ], + "result": { + "name": "computeFeeResponse", + "description": "Array of computed fee information.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "asset"], + "properties": { + "type": { + "type": "string", + "enum": ["base", "priority"], + "description": "The fee type." + }, + "asset": { + "type": "object", + "required": ["unit", "type", "amount", "fungible"], + "properties": { + "unit": { + "type": "string", + "description": "The currency unit for the account's network." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset identifier for the account's network." + }, + "amount": { + "type": "string", + "description": "The fee amount as a string." + }, + "fungible": { + "type": "boolean", + "description": "Whether the asset is fungible." + } + } + } + } + } + } + } + }, + { + "name": "onAddressInput", + "description": "Validates a Bitcoin address for a send transaction.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "description": "The Bitcoin address to validate.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "accountId", + "description": "Current account.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "validationResponse", + "description": "Validation result with any errors.", + "schema": { + "type": "object", + "required": ["valid", "errors"], + "properties": { + "valid": { + "type": "boolean", + "description": "The address is valid." + }, + "errors": { + "type": "array", + "description": "In case the address value is invalid.", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "type": "string", + "enum": ["Required", "Invalid"], + "description": "Error code." + } + } + } + } + } + } + } + }, + { + "name": "onAmountInput", + "description": "Validates a transaction amount for a send transaction. Checks for sufficient balance.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "description": "The amount to send.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "accountId", + "description": "From where we will get native balance.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetId", + "description": "From where we will get balances.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "validationResponse", + "description": "Validation result with any errors.", + "schema": { + "type": "object", + "required": ["valid", "errors"], + "properties": { + "valid": { + "type": "boolean", + "description": "The amount is valid." + }, + "errors": { + "type": "array", + "description": "In case the amount value is invalid.", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "type": "string", + "enum": [ + "Required", + "InsufficientBalanceToCoverFee", + "InsufficientBalance" + ], + "description": "Error code." + } + } + } + } + } + } + } + }, + { + "name": "confirmSend", + "description": "Creates a Bitcoin transaction for confirmation. Returns a KeyringTransaction object.", + "paramStructure": "by-name", + "params": [ + { + "name": "fromAccountId", + "description": "The account ID from which to send.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "toAddress", + "description": "The recipient Bitcoin address.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "amount", + "description": "The amount to send in BTC.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetId", + "description": "The CAIP-19 asset identifier.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "keyringTransaction", + "description": "The KeyringTransaction object representing the transaction. Throws an error if the operation fails.", + "schema": { + "type": "object", + "description": "Transaction object", + "required": [ + "id", + "account", + "chain", + "type", + "status", + "timestamp", + "events", + "to", + "from", + "fees", + "canBeMalleable" + ], + "properties": { + "id": { + "type": "string", + "description": "The transaction ID." + }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." + }, + "account": { + "type": "string", + "description": "The account ID that created this transaction." + }, + "chain": { + "type": "string", + "description": "The chain scope (e.g., 'bip122:000000000019d6689c085ae165831e93')." + }, + "type": { + "type": "string", + "enum": ["send", "receive"], + "description": "The transaction type." + }, + "status": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "failed"], + "description": "The transaction status." + }, + "timestamp": { + "type": ["number", "null"], + "description": "The timestamp when the transaction was created." + }, + "events": { + "type": "array", + "description": "Transaction status change events.", + "items": { + "type": "object", + "required": ["status", "timestamp"], + "properties": { + "status": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "failed"] + }, + "timestamp": { + "type": ["number", "null"] + } + } + } + }, + "to": { + "type": "array", + "description": "Recipients of the transaction.", + "items": { + "type": "object", + "required": ["address", "asset"], + "properties": { + "address": { + "type": "string", + "description": "The recipient address." + }, + "asset": { + "type": "object", + "required": ["amount", "fungible", "unit", "type"], + "properties": { + "amount": { + "type": "string", + "description": "The amount in BTC." + }, + "fungible": { + "type": "boolean", + "const": true + }, + "unit": { + "type": "string", + "description": "The currency unit (e.g., 'BTC')." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset type." + } + } + } + } + } + }, + "from": { + "type": "array", + "description": "Senders of the transaction (typically empty for Bitcoin).", + "items": { + "type": "object" + } + }, + "fees": { + "type": "array", + "description": "Transaction fees.", + "items": { + "type": "object", + "required": ["type", "asset"], + "properties": { + "type": { + "type": "string", + "enum": ["priority"], + "description": "The fee type." + }, + "asset": { + "type": "object", + "required": ["amount", "fungible", "unit", "type"], + "properties": { + "amount": { + "type": "string", + "description": "The fee amount in BTC." + }, + "fungible": { + "type": "boolean", + "const": true + }, + "unit": { + "type": "string", + "description": "The currency unit (e.g., 'BTC')." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset type." + } + } + } + } + } + } + } + } + } + } + ] +} diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json new file mode 100644 index 00000000..377c3b1b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -0,0 +1,68 @@ +{ + "name": "@metamask/bitcoin-wallet-snap", + "version": "1.15.0", + "description": "A Bitcoin wallet Snap.", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" + }, + "license": "(MIT-0 OR Apache-2.0)", + "main": "./dist/bundle.js", + "files": [ + "dist/", + "images/", + "snap.manifest.json", + "locales/" + ], + "scripts": { + "allow-scripts": "yarn workspace root allow-scripts", + "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap", + "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", + "build:clean": "yarn clean && yarn build:locale && yarn build", + "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", + "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bitcoin-wallet-snap", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bitcoin-wallet-snap", + "clean": "rimraf dist", + "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", + "lint:deps": "depcheck && yarn dedupe --check", + "lint:deps:fix": "depcheck && yarn dedupe", + "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier '**/*.json' '**/*.md' --check", + "lint:types": "tsc --noEmit", + "publish:preview": "yarn npm publish --tag preview", + "prepublishOnly": "mm-snap manifest", + "serve": "mm-snap serve", + "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", + "test": "jest", + "test:integration": "./integration-test/run-integration.sh" + }, + "devDependencies": { + "@jest/globals": "^30.0.5", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/bitcoindevkit": "^0.1.13", + "@metamask/key-tree": "^10.1.1", + "@metamask/keyring-api": "^22.0.0", + "@metamask/keyring-snap-sdk": "^8.0.0", + "@metamask/slip44": "^4.2.0", + "@metamask/snaps-cli": "^8.3.0", + "@metamask/snaps-jest": "^9.8.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/utils": "^11.9.0", + "bip322-js": "^3.0.0", + "concurrently": "^9.2.1", + "dotenv": "^17.2.1", + "jest": "^30.0.5", + "jest-mock-extended": "^4.0.0", + "jest-transform-stub": "2.0.0", + "superstruct": "^2.0.2", + "ts-jest": "^29.4.1", + "uuid": "^14.0.0", + "wif": "^5.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js new file mode 100644 index 00000000..74a220b2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js @@ -0,0 +1,80 @@ +// @ts-check + + +const { readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const packageFile = require('../package.json'); + +console.log('[preinstalled-snap] - attempt to build preinstalled snap'); + +/** + * Read the contents of a file and return as a string. + * @param {string} filePath - Path to file. + * @returns {string} File as utf-8 string. + */ +function readFileContents(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`Error reading file from disk: ${filePath}`, error); + throw error; + } +} + +// Paths to the files +const bundlePath = require.resolve('../dist/bundle.js'); +const iconPath = require.resolve('../images/icon.svg'); +const manifestPath = require.resolve('../snap.manifest.json'); + +// File Contents +const bundle = readFileContents(bundlePath); +const icon = readFileContents(iconPath); +const manifest = readFileContents(manifestPath); + +const parsedManifest = JSON.parse(manifest); + +const localeFiles = parsedManifest.source.locales.map((localePath) => { + const fullPath = require.resolve(`../${localePath}`); + const contents = readFileContents(fullPath); + return { path: localePath, value: contents }; +}); + +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, + }, + ...localeFiles, + ], + 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/bitcoin-wallet-snap/scripts/populate-en-locale.js b/merged-packages/bitcoin-wallet-snap/scripts/populate-en-locale.js new file mode 100644 index 00000000..e3f1ee6c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/scripts/populate-en-locale.js @@ -0,0 +1,26 @@ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const messages = require('../messages.json'); + +console.log('[populate-en-locale] - attempt to populate en locale'); + +const englishLocale = { + locale: 'en', + messages: Object.entries(messages).reduce((acc, [key, { message }]) => { + acc[key] = { message }; + return acc; + }, {}), +}; + +// Write en locale file +try { + writeFileSync( + join(__dirname, '../locales/en.json'), + JSON.stringify(englishLocale, null, 2), + ); + console.log('[populate-en-locale] - en locale populated'); +} catch (error) { + console.error('Error writing en locale file', error); + throw error; +} diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts new file mode 100644 index 00000000..5fd9a71e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -0,0 +1,31 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; +import { config as dotenv } from 'dotenv'; +import { resolve } from 'path'; + +dotenv(); + +const config: SnapConfig = { + input: resolve(__dirname, 'src/index.ts'), + server: { + port: 8080, + }, + environment: { + LOG_LEVEL: process.env.LOG_LEVEL, + DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, + ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, + ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, + ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, + ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, + ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, + PRICE_API_URL: process.env.PRICE_API_URL, + BITCOIN_EXPLORER: process.env.BITCOIN_EXPLORER, + TESTNET_EXPLORER: process.env.TESTNET_EXPLORER, + TESTNET4_EXPLORER: process.env.TESTNET4_EXPLORER, + SIGNET_EXPLORER: process.env.SIGNET_EXPLORER, + REGTEST_EXPLORER: process.env.REGTEST_EXPLORER, + }, + polyfills: true, + experimental: { wasm: true }, +}; + +export default config; diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json new file mode 100644 index 00000000..8a06e8f0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -0,0 +1,97 @@ +{ + "version": "1.14.1", + "description": "Manage Bitcoin using MetaMask", + "proposedName": "Bitcoin", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" + }, + "source": { + "shasum": "AJMM/qNI5aAedy2QgWak4Ms+B1e5r0VQBbRoJthPXnw=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "iconPath": "images/icon.svg", + "packageName": "@metamask/bitcoin-wallet-snap", + "registry": "https://registry.npmjs.org/" + } + }, + "locales": [ + "locales/de.json", + "locales/en.json", + "locales/es.json", + "locales/fr.json", + "locales/ja.json", + "locales/ru.json", + "locales/tl.json", + "locales/tr.json", + "locales/vi.json", + "locales/zh_CN.json" + ] + }, + "initialPermissions": { + "endowment:webassembly": {}, + "endowment:keyring": {}, + "snap_getBip32Entropy": [ + { + "path": ["m", "44'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "44'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "49'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "49'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "84'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "84'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "86'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "86'", "1'"], + "curve": "secp256k1" + } + ], + "endowment:lifecycle-hooks": {}, + "endowment:network-access": {}, + "snap_manageAccounts": {}, + "snap_manageState": {}, + "snap_dialog": {}, + "snap_getPreferences": {}, + "endowment:cronjob": { + "jobs": [ + { + "duration": "PT30S", + "request": { + "method": "synchronizeAccounts" + } + } + ] + }, + "endowment:assets": { + "scopes": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + "platformVersion": "11.1.1", + "manifestVersion": "0.1" +} diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts new file mode 100644 index 00000000..1022af74 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-restricted-globals */ + +import type { AddressType } from '@metamask/bitcoindevkit'; + +import { LogLevel, type SnapConfig } from './entities'; + +const ENV = { + LOG_LEVEL: process.env.LOG_LEVEL, + DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, + ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, + ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, + ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, + ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, + ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, + PRICE_API_URL: process.env.PRICE_API_URL, + BITCOIN_EXPLORER: process.env.BITCOIN_EXPLORER, + TESTNET_EXPLORER: process.env.TESTNET_EXPLORER, + TESTNET4_EXPLORER: process.env.TESTNET4_EXPLORER, + SIGNET_EXPLORER: process.env.SIGNET_EXPLORER, + REGTEST_EXPLORER: process.env.REGTEST_EXPLORER, +} as const; + +const fromEnv = (key: string, fallback: string): string => { + const value = ENV[key as keyof typeof ENV]; + return value && value.trim() !== '' ? value : fallback; +}; + +export const Config: SnapConfig = { + logLevel: fromEnv('LOG_LEVEL', LogLevel.INFO) as LogLevel, + encrypt: false, + chain: { + parallelRequests: 5, + stopGap: 5, + maxRetries: 3, + url: { + bitcoin: fromEnv('ESPLORA_BITCOIN', 'https://blockstream.info/api'), + testnet: fromEnv( + 'ESPLORA_TESTNET', + 'https://blockstream.info/testnet/api', + ), + testnet4: fromEnv( + 'ESPLORA_TESTNET4', + 'https://mempool.space/testnet4/api/v1', + ), + signet: fromEnv('ESPLORA_SIGNET', 'https://mutinynet.com/api'), + regtest: fromEnv('ESPLORA_REGTEST', 'http://localhost:8094/regtest/api'), + }, + explorerUrl: { + bitcoin: fromEnv('BITCOIN_EXPLORER', 'https://mempool.space'), + testnet: fromEnv('TESTNET_EXPLORER', 'https://mempool.space/testnet'), + testnet4: fromEnv('TESTNET4_EXPLORER', 'https://mempool.space/testnet4'), + signet: fromEnv('SIGNET_EXPLORER', 'https://mutinynet.com'), + regtest: fromEnv('REGTEST_EXPLORER', 'http://localhost:8094/regtest'), + }, + }, + targetBlocksConfirmation: 1, + fallbackFeeRate: 5.0, + ratesRefreshInterval: 'PT20S', + priceApi: { + url: fromEnv('PRICE_API_URL', 'https://price.api.cx.metamask.io'), + }, + conversionsExpirationInterval: 60, + defaultAddressType: fromEnv('DEFAULT_ADDRESS_TYPE', 'p2wpkh') as AddressType, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts new file mode 100644 index 00000000..7dc62017 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts @@ -0,0 +1,15 @@ +import type { AddressType } from '@metamask/bitcoindevkit'; + +import { canAccountTxidBeMalleated } from './account'; + +describe('canAccountTxidBeMalleated', () => { + it.each<[AddressType, boolean]>([ + ['p2pkh', true], + ['p2sh', false], + ['p2wpkh', false], + ['p2wsh', false], + ['p2tr', false], + ])('returns %s -> %s', (addressType, expected) => { + expect(canAccountTxidBeMalleated(addressType)).toBe(expected); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts new file mode 100644 index 00000000..f8d27eee --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -0,0 +1,411 @@ +import type { + FullScanRequest, + SyncRequest, + AddressInfo, + AddressType, + Balance, + Network, + Update, + ChangeSet, + Psbt, + Transaction, + LocalOutput, + WalletTx, + Amount, + ScriptBuf, + Address, +} from '@metamask/bitcoindevkit'; + +import type { Inscription } from './meta-protocols'; +import type { TransactionBuilder } from './transaction'; + +/** + * A Bitcoin account. + */ +export type BitcoinAccount = { + /** + * The id of the account. + */ + id: string; + + /** + * Derivation path. + */ + derivationPath: string[]; + + /** + * Account entropy source. + */ + entropySource: string; + + /** + * BIP44 Account index. + */ + accountIndex: number; + + /** + * The balance of the account. + */ + balance: Balance; + + /** + * The address type of the account. + */ + addressType: AddressType; + + /** + * The network in which the account operates. + */ + network: Network; + + /** + * The public address representing this account. Usually address at index 0. + */ + publicAddress: Address; + + /** + * The public descriptor of the account. + */ + publicDescriptor: string; + + /** + * The capabilities of the account. + */ + capabilities: AccountCapability[]; + + /** + * Get an address at a given index. + * + * @param index - derivation index. + * @returns the address + */ + peekAddress(index: number): AddressInfo; + + /** + * Get the next unused address. This will reveal a new address if there is no unused address. + * Note that the account needs to be persisted for this operation to be idempotent. + * + * @returns the address + */ + nextUnusedAddress(): AddressInfo; + + /** + * Reveal the next address. + * Note that the account needs to be persisted for this operation to be idempotent. + * + * @returns the address + */ + revealNextAddress(): AddressInfo; + + /** + * Start a full scan. + * + * @returns the full scan request + */ + startFullScan(): FullScanRequest; + + /** + * Start a sync with revealed scripts. + * + * @returns the sync request + */ + startSync(): SyncRequest; + + /** + * Apply an update to the account. + */ + applyUpdate(update: Update): void; + + /** + * Extract the change set if it exists. + * + * @returns the change set + */ + takeStaged(): ChangeSet | undefined; + + /** + * Check whether a change set exists without making it unavailable for extraction. + * + * @returns true if there is a change set + */ + hasStaged(): boolean; + + /** + * Returns a Transaction Builder. + * + * @returns the TxBuilder + */ + buildTx(): TransactionBuilder; + + /** + * Sign a PSBT with all the registered signers + * + * @param psbt - The PSBT to be signed. + * @returns the signed PSBT + */ + sign(psbt: Psbt): Psbt; + + /** + * Extract the transaction from a PSBT. + * + * @param psbt - The PSBT. + * @param maxFeeRate - The maximum fee rate to use for the transaction. + * @returns the transaction + */ + extractTransaction(psbt: Psbt, maxFeeRate?: number): Transaction; + + /** + * Get a UTXO by outpoint. + * + * @param outpoint - Outpoint of the utxo in the format :. + * @returns the wallet UTXO or undefined if the UTXO is not found + */ + getUtxo(outpoint: string): LocalOutput | undefined; + + /** + * Get the list of UTXOs + * + * @returns the list of UTXOs + */ + listUnspent(): LocalOutput[]; + + /** + * List relevant and canonical transactions in the wallet. + * A transaction is relevant when it spends from or spends to at least one tracked output. + * A transaction is canonical when it is confirmed in the best chain, or does not conflict with any transaction confirmed in the best chain. + * + * @returns the list of wallet transactions + */ + listTransactions(): WalletTx[]; + + /** + * Get a single transaction from the wallet as a [`WalletTx`] (if the transaction exists). + * + * @returns the wallet transaction + */ + getTransaction(txid: string): WalletTx | undefined; + + /** + * Calculate the fee of a given transaction. Returns [`Amount::ZERO`] if `tx` is a coinbase transaction. + * + * @param tx - The transaction. + * @returns the fee amount. + */ + calculateFee(tx: Transaction): Amount; + + /** + * Return whether or not a `script` is part of this wallet (either internal or external). + * + * @param script - The Bitcoin script. + * @returns the ownership state. + */ + isMine(script: ScriptBuf): boolean; + + /** + * Compute the `tx`'s sent and received [`Amount`]s. + * This method returns a tuple `(sent, received)`. Sent is the sum of the txin amounts + * that spend from previous txouts tracked by this wallet. Received is the summation + * of this tx's outputs that send to script pubkeys tracked by this wallet. + * + * @param tx - The Bitcoin transaction. + * @returns the sent and received amounts. + */ + sentAndReceived(tx: Transaction): [Amount, Amount]; + + /** + * Apply relevant unconfirmed transactions to the wallet. + * Transactions that are not relevant are filtered out. + * + * @param tx - The Bitcoin transaction. + * @param lastSeen - Timestamp of when the transaction was last seen in the mempool. + */ + applyUnconfirmedTx(tx: Transaction, lastSeen: number): void; +}; + +export enum AccountCapability { + SignPsbt = 'signPsbt', + ComputeFee = 'computeFee', + FillPsbt = 'fillPsbt', + BroadcastPsbt = 'broadcastPsbt', + SendTransfer = 'sendTransfer', + GetUtxo = 'getUtxo', + ListUtxos = 'listUtxos', + PublicDescriptor = 'publicDescriptor', + SignMessage = 'signMessage', +} + +/** + * BitcoinAccountRepository is a repository that manages Bitcoin accounts. + */ +export type BitcoinAccountRepository = { + /** + * Get an account by its id. + * + * @param id - Account ID. + * @returns the account or null if it does not exist + */ + get(id: string): Promise; + + /** + * Get an account by its id with signing capabilities + * + * @param id - Account ID. + * @returns the account or null if it does not exist + */ + getWithSigner(id: string): Promise; + + /** + * Get all accounts. + * + * @returns the list of accounts + */ + getAll(): Promise; + + /** + * Get an account by its derivation path. + * + * @param derivationPath - derivation path. + * @returns the account or null if it does not exist + */ + getByDerivationPath(derivationPath: string[]): Promise; + + /** + * Get accounts by derivation path. + * + * @param derivationPaths - derivation paths. + * @returns the accounts or null if they do not exist, in input order + */ + getByDerivationPaths( + derivationPaths: string[][], + ): Promise<(BitcoinAccount | null)[]>; + + /** + * Create a new account, without persisting it. + * + * @param derivationPath - derivation path. + * @param network - network. + * @param addressType - address type. + * @returns the new account + */ + create( + derivationPath: string[], + network: Network, + addressType: AddressType, + ): Promise; + + /** + * Insert an account. + * + * @param account - Bitcoin account. + */ + insert(account: BitcoinAccount): Promise; + + /** + * Insert accounts. + * + * @param accounts - Bitcoin accounts. + */ + insertMany(accounts: BitcoinAccount[]): Promise; + + /** + * Update an account. + * + * @param account - Bitcoin account. + * @param inscriptions - List of inscriptions. + */ + update(account: BitcoinAccount, inscriptions?: Inscription[]): Promise; + + /** + * Delete an account. + * + * @param id - Account ID. + * @returns true if the account has been deleted. + */ + delete(id: string): Promise; + + /** + * Get the list of frozen UTXO outpoints of an account. + * + * @param id - Account ID. + * @returns the frozen UTXO outpoints. + */ + getFrozenUTXOs(id: string): Promise; +}; + +export enum Purpose { + Legacy = 44, + Segwit = 49, + NativeSegwit = 84, + Taproot = 86, + Multisig = 45, +} + +export enum Slip44 { + Bitcoin = 0, + Testnet = 1, +} + +export const addressTypeToPurpose: Record = { + p2pkh: Purpose.Legacy, + p2sh: Purpose.Segwit, + p2wsh: Purpose.Multisig, + p2wpkh: Purpose.NativeSegwit, + p2tr: Purpose.Taproot, +}; + +export const purposeToAddressType: Record = { + [Purpose.Legacy]: 'p2pkh', + [Purpose.Segwit]: 'p2sh', + [Purpose.Multisig]: 'p2wsh', + [Purpose.NativeSegwit]: 'p2wpkh', + [Purpose.Taproot]: 'p2tr', +}; + +export const networkToCoinType: Record = { + bitcoin: Slip44.Bitcoin, + testnet: Slip44.Testnet, + testnet4: Slip44.Testnet, + signet: Slip44.Testnet, + regtest: Slip44.Testnet, +}; + +/** + * Whether transactions broadcast from an account of each AddressType can + * have their txid changed by a third party (transaction malleability) + * before confirmation. + * + * Only legacy P2PKH (BIP44) carries signatures in scriptSig and is therefore + * malleable. Every other supported AddressType puts signatures in witness + * data, which is excluded from the txid hash. Note that `p2sh` here means + * BIP49 wrapped SegWit (sh(wpkh(...))), not generic legacy P2SH; its + * scriptSig is a fixed canonical push of the witness program and signatures + * live in the witness. If support for arbitrary legacy P2SH descriptors + * (bare multisig, custom redeem scripts with signatures in scriptSig) is + * added later, this table must be revisited — do not blindly keep the + * `p2sh` entry as `false`. + * + * Compile-time exhaustiveness: `satisfies Record` + * forces every AddressType variant to appear as a key. If a new variant is + * ever added upstream, this object literal becomes a TypeScript error + * until a deliberate malleability decision is recorded here. + */ +const ADDRESS_TYPE_TXID_MALLEABILITY = { + p2pkh: true, + p2sh: false, + p2wpkh: false, + p2wsh: false, + p2tr: false, +} as const satisfies Record; + +/** + * Whether transactions broadcast from an account of the given address type + * can have their txid changed by a third party (transaction malleability) + * before confirmation. + * + * @param addressType - The account's address type. + * @returns `true` if a third party can rewrite the txid of a transaction + * broadcast from this account before confirmation; `false` otherwise. + */ +export function canAccountTxidBeMalleated(addressType: AddressType): boolean { + return ADDRESS_TYPE_TXID_MALLEABILITY[addressType]; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts new file mode 100644 index 00000000..b6361de8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts @@ -0,0 +1,213 @@ +import type { + Amount, + LocalOutput, + WalletTx, + Transaction, +} from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from './account'; +import { computeDisplayBalanceSats } from './balance'; + +/* eslint-disable @typescript-eslint/naming-convention */ + +const sat = (value: bigint): Amount => + ({ + to_sat: () => value, + }) as unknown as Amount; + +const mockUtxo = (opts: { + keychain: 'external' | 'internal'; + txidString: string; + valueSats: bigint; +}): LocalOutput => + ({ + keychain: opts.keychain, + outpoint: { + txid: { toString: () => opts.txidString }, + }, + txout: { value: sat(opts.valueSats) }, + }) as unknown as LocalOutput; + +const mockWalletTx = (isConfirmed: boolean): WalletTx => + ({ + tx: {} as Transaction, + chain_position: { is_confirmed: isConfirmed }, + }) as unknown as WalletTx; + +describe('computeDisplayBalanceSats', () => { + const buildAccount = (overrides: { + trustedSpendableSats: bigint; + utxos: LocalOutput[]; + txByTxid: Record; + sentByTxid: Record; + }): BitcoinAccount => { + const account = mock(); + Object.defineProperty(account, 'balance', { + get: () => + ({ + trusted_spendable: sat(overrides.trustedSpendableSats), + }) as never, + }); + account.listUnspent.mockReturnValue(overrides.utxos); + account.getTransaction.mockImplementation((txid) => { + return overrides.txByTxid[txid]; + }); + account.sentAndReceived.mockImplementation((tx) => { + // Match by reference: find the txid whose mocked WalletTx.tx === tx. + for (const [txid, walletTx] of Object.entries(overrides.txByTxid)) { + if (walletTx && walletTx.tx === tx) { + return [sat(overrides.sentByTxid[txid] ?? 0n), sat(0n)]; + } + } + return [sat(0n), sat(0n)]; + }); + return account; + }; + + it('returns trusted_spendable when there are no relevant unspents', () => { + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [], + txByTxid: {}, + sentByTxid: {}, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips internal-keychain unspents (already in trusted_pending)', () => { + const utxo = mockUtxo({ + keychain: 'internal', + txidString: 'tx_internal', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_internal: mockWalletTx(false) }, + sentByTxid: { tx_internal: 200n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips external-keychain unspents whose tx is already confirmed', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_confirmed', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_confirmed: mockWalletTx(true) }, + sentByTxid: { tx_confirmed: 200n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips external-keychain unspents from foreign transactions (sent === 0)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_incoming', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_incoming: mockWalletTx(false) }, + sentByTxid: { tx_incoming: 0n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('includes external-keychain change from our own broadcasts (issue #597)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_self', + valueSats: 999_900_243n, + }); + const account = buildAccount({ + trustedSpendableSats: 0n, + utxos: [utxo], + txByTxid: { tx_self: mockWalletTx(false) }, + sentByTxid: { tx_self: 1_000_000_000n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(999_900_243n); + }); + + it('skips unspents whose parent tx is unknown (defensive)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_missing', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: {}, + sentByTxid: {}, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('sums trusted_spendable and multiple qualifying external unspents', () => { + const owned = mockUtxo({ + keychain: 'external', + txidString: 'tx_self', + valueSats: 50n, + }); + const skippedInternal = mockUtxo({ + keychain: 'internal', + txidString: 'tx_int', + valueSats: 70n, + }); + const skippedConfirmed = mockUtxo({ + keychain: 'external', + txidString: 'tx_conf', + valueSats: 30n, + }); + const skippedIncoming = mockUtxo({ + keychain: 'external', + txidString: 'tx_in', + valueSats: 20n, + }); + const ownedSecond = mockUtxo({ + keychain: 'external', + txidString: 'tx_self2', + valueSats: 25n, + }); + + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [ + owned, + skippedInternal, + skippedConfirmed, + skippedIncoming, + ownedSecond, + ], + txByTxid: { + tx_self: mockWalletTx(false), + tx_int: mockWalletTx(false), + tx_conf: mockWalletTx(true), + tx_in: mockWalletTx(false), + tx_self2: mockWalletTx(false), + }, + sentByTxid: { + tx_self: 100n, + tx_int: 100n, + tx_conf: 100n, + tx_in: 0n, + tx_self2: 100n, + }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n + 50n + 25n); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts b/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts new file mode 100644 index 00000000..043d368e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts @@ -0,0 +1,46 @@ +import type { BitcoinAccount } from './account'; + +/** + * Compute the spendable balance to display to the user, in satoshis. + * + * BDK's `Balance.trusted_spendable` only counts unconfirmed change UTXOs + * landing on the *internal* keychain. When the snap fills a partner-supplied + * PSBT (bridge/swap), the change output's script is dictated by the template + * and typically drains to the user's *external* (public) address — so BDK + * conservatively classifies that change as `untrusted_pending` and the user + * sees their balance flash to zero until the tx confirms. + * + * We extend BDK's "trusted spendable" by also counting any unconfirmed UTXO + * whose parent transaction was created by this wallet itself (i.e. spends + * from one of our own inputs). Genuinely untrusted incoming unconfirmed + * funds from third parties remain excluded. + * + * @param account - The Bitcoin account to inspect. + * @returns The available balance in satoshis. + */ +export function computeDisplayBalanceSats(account: BitcoinAccount): bigint { + let sats = account.balance.trusted_spendable.to_sat(); + + for (const utxo of account.listUnspent()) { + // Already counted by BDK as `trusted_pending` (change to internal keychain) + // or as `confirmed` once anchored. + if (utxo.keychain === 'internal') { + continue; + } + + const walletTx = account.getTransaction(utxo.outpoint.txid.toString()); + if (!walletTx || walletTx.chain_position.is_confirmed) { + continue; + } + + // `sent > 0` means the wallet contributed inputs to this transaction, + // so the output landing back on our own external script is trusted — + // a third party cannot have produced it without one of our keys. + const [sent] = account.sentAndReceived(walletTx.tx); + if (sent.to_sat() > 0n) { + sats += utxo.txout.value.to_sat(); + } + } + + return sats; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts new file mode 100644 index 00000000..112f916e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -0,0 +1,57 @@ +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; + +import type { BitcoinAccount } from './account'; + +export const BlockTime: Record = { + bitcoin: 10, + testnet: 10, + testnet4: 10, + signet: 0.5, + regtest: 0.5, +}; + +export type BlockchainClient = { + /** + * Perform a full scan operation on the account. + * Note that this operation modifies the account in place. + * + * @param account - the account to full scan. + */ + fullScan(account: BitcoinAccount): Promise; + + /** + * Perform a sync operation on the account. + * Note that this operation modifies the account in place. + * + * @param account - the account to sync. + */ + sync(account: BitcoinAccount): Promise; + + /** + * Broadcast the signed transaction to the network. + * + * @param network - Network where the signed transaction will be broadcasted. + * @param transaction - Transaction to broadcast. + */ + broadcast(network: Network, transaction: Transaction): Promise; + + /** + * Fetch fee estimates from the chain indexer. + * + * @param network - Network to fetch the fees from. + * @returns the map of fee estimates + */ + getFeeEstimates(network: Network): Promise; + + /** + * Returns the explorer url for the given network. + * + * @param network - Network. + * @returns the base url of the explorer + */ + getExplorerUrl(network: Network): string; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts new file mode 100644 index 00000000..ea7e5d59 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -0,0 +1,31 @@ +import type { AddressType, Network } from '@metamask/bitcoindevkit'; + +import type { LogLevel } from './logger'; + +export type SnapConfig = { + logLevel: LogLevel; + encrypt: boolean; + chain: ChainConfig; + targetBlocksConfirmation: number; // Temporary global config to set the expected confirmation target, should eventually be a user setting + fallbackFeeRate: number; + ratesRefreshInterval: string; + priceApi: PriceApiConfig; + conversionsExpirationInterval: number; + defaultAddressType: AddressType; +}; + +export type ChainConfig = { + parallelRequests: number; + stopGap: number; + maxRetries: number; + url: { + [network in Network]: string; + }; + explorerUrl: { + [network in Network]: string; + }; +}; + +export type PriceApiConfig = { + url: string; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts new file mode 100644 index 00000000..3a079b56 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts @@ -0,0 +1,97 @@ +import type { Network, Psbt } from '@metamask/bitcoindevkit'; + +import type { BitcoinAccount } from './account'; +import type { CurrencyRate, CurrencyUnit } from './currency'; + +export type SignMessageConfirmationContext = { + message: string; + account: { + id: string; + address: string; // FIXME: Address should not be needed to identify an account + }; + network: Network; + origin: string; +}; + +export type SignPsbtOutput = { + address?: string; + amount: string; + isMine: boolean; + isOpReturn: boolean; +}; + +export type SignPsbtConfirmationContext = { + psbt: string; + account: { + id: string; + address: string; + }; + network: Network; + origin: string; + options: { + fill: boolean; + broadcast: boolean; + }; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + fee?: string; + outputs: SignPsbtOutput[]; + inputCount: number; +}; + +export enum ConfirmationEvent { + Confirm = 'confirmation-confirm', + Cancel = 'confirmation-cancel', +} + +/** + * ConfirmationRepository is a repository that manages request confirmations for dApps. + */ +export type ConfirmationRepository = { + /** + * Inserts a sign message confirmation interface. + * + * @param account - The account to sign the message. + * @param message - The message to sign. + * @param origin - The origin of the request. + */ + insertSignMessage( + account: BitcoinAccount, + message: string, + origin: string, + ): Promise; + + /** + * Inserts a send transfer confirmation interface. + * + * @param account - The account sending the transfer. + * @param psbt - The PSBT of the transfer. + * @param recipient - The recipient of the transfer. + * @param recipient.address - The address of the recipient. + * @param recipient.amount - The amount to send to the recipient. + * @param origin - The origin of the request. + */ + insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise; + + /** + * Inserts a sign PSBT confirmation interface. + * + * @param account - The account to sign the PSBT. + * @param psbt - The PSBT to sign (as Psbt object). + * @param origin - The origin of the request. + * @param options - The sign options (fill, broadcast). + * @param options.fill - Whether to fill the PSBT. + * @param options.broadcast - Whether to broadcast the PSBT. + */ + insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts new file mode 100644 index 00000000..1b71c644 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -0,0 +1,23 @@ +import type { Network } from '@metamask/bitcoindevkit'; + +export enum CurrencyUnit { + Bitcoin = 'BTC', + Testnet = 'tBTC', + Signet = 'sBTC', + Regtest = 'rBTC', + Fiat = 'fiat', // Can also be cryptos like ETH, but will be fiat for 99% of users +} + +export type CurrencyRate = { + conversionRate: number; + conversionDate: number; + currency: string; +}; + +export const networkToCurrencyUnit: Record = { + bitcoin: CurrencyUnit.Bitcoin, + testnet: CurrencyUnit.Testnet, + testnet4: CurrencyUnit.Testnet, + signet: CurrencyUnit.Signet, + regtest: CurrencyUnit.Regtest, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts new file mode 100644 index 00000000..456132e6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts @@ -0,0 +1,182 @@ +import type { Json } from '@metamask/utils'; + +export class BaseError extends Error { + code: number; + + data?: Record; + + cause?: unknown; + + constructor( + message: string, + code: number, + data?: Record, + cause?: unknown, + ) { + super(message); + this.code = code; + this.data = data; + this.cause = cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export type CodifiedError = { + name: string; + message: string; + code: number; + data?: Record; + stack: string | null; +}; + +/** + * Error thrown when the format of input data is invalid. Should never be thrown outside handlers. + * Useful for signaling parsing or type errors. + * + * @example + * throw new FormatError('Invalid address format'); + */ +export class FormatError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 0, data, cause); + this.name = 'FormatError'; + } +} + +/** + * Error thrown when input data fails validation rules. Should never be thrown outside use cases. + * Useful for signaling failed schema or business logic validation. + * + * @example + * throw new ValidationError('Amount must be positive'); + */ +export class ValidationError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 1000, data, cause); + this.name = 'ValidationError'; + } +} + +/** + * Error thrown when a requested resource cannot be found. + * Useful for signaling missing data or entities. + * + * @example + * throw new NotFoundError('Account not found'); + */ +export class NotFoundError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 2000, data, cause); + this.name = 'NotFoundError'; + } +} + +/** + * Error thrown when an external service or dependency fails. + * Useful for signaling issues with APIs, blockchain explorers, etc. + * + * @example + * throw new ExternalServiceError('Price API unavailable'); + */ +export class ExternalServiceError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 3000, data, cause); + this.name = 'ExternalServiceError'; + } +} + +/** + * Error thrown when accounts fail to synchronize. Used to gather multiple potential errors. + * + * @example + * throw new SynchronizationError('Accounts failed to synchronize'); + */ +export class SynchronizationError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 3100, data, cause); // Under ExternalServiceError + this.name = 'SynchronizationError'; + } +} + +/** + * Error thrown for wallet-specific failures. + * Useful for signaling wallet operation errors. + * + * @example + * throw new WalletError('Insufficient funds'); + */ +export class WalletError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 4000, data, cause); + this.name = 'WalletError'; + } +} + +/** + * Error thrown when storage operations fail. + * Useful for signaling database or persistence errors. + * + * @example + * throw new StorageError('Failed to insert account'); + */ +export class StorageError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 5000, data, cause); + this.name = 'StorageError'; + } +} + +/** + * Error thrown when a requested method or resource does not exist or is not implemented. + * Useful for signaling "method not found" or "not implemented" cases. + * + * @example + * throw new InexistentError('Method not implemented'); + */ +export class InexistentMethodError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 6000, data, cause); + this.name = 'InexistentMethodError'; + } +} + +/** + * Error thrown when an operation is not permitted due to insufficient permissions or authorization failure. + * Useful for signaling access control violations, such as invalid origin. + * + * @example + * throw new PermissionError('Invalid origin'); + */ +export class PermissionError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 7000, data, cause); + this.name = 'PermissionError'; + } +} + +/** + * Error thrown when an operation is failing in a user interface, such as forms, prompts, or confirmations. + * + * @example + * throw new UserActionError('User canceled the send flow'); + */ +export class UserActionError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 8000, data, cause); + this.name = 'UserActionError'; + } +} + +/** + * Error thrown when an assertion fails. These are errors that should never happen outside of developer errors or bugs. + * Useful for signaling unexpected conditions that should be fixed in the code. + * + * @example + * throw new AssertionError('Inconsistent state detected. Expected X, got Y', { state }); + */ +export class AssertionError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 9000, data, cause); + this.name = 'AssertionError'; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts new file mode 100644 index 00000000..3584fa42 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -0,0 +1,14 @@ +export * from './account'; +export * from './balance'; +export type * from './config'; +export * from './chain'; +export * from './currency'; +export * from './send-flow'; +export type * from './transaction'; +export * from './snap'; +export type * from './meta-protocols'; +export type * from './translator'; +export type * from './rates'; +export * from './logger'; +export * from './error'; +export * from './confirmation'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts new file mode 100644 index 00000000..4fcc85f2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts @@ -0,0 +1,58 @@ +export enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', + TRACE = 'trace', + SILENT = 'silent', +} + +/** + * A Logger. + */ +export type Logger = { + /** + * Logs at the `ERROR` level. + * + * @param data - The data to log. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error(...data: any[]): void; + + /** + * Logs at the `WARN` level. + * + * @param data - The data to log. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn(...data: any[]): void; + + /** + * Logs at the `INFO` level. + * + * @param data - The data to log. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info(...data: any[]): void; + + /** + * Logs at the `DEBUG` level. + * + * @param data - The data to log. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug(...data: any[]): void; + + /** + * Logs at the `TRACE` level. + * + * @param data - The data to log. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + trace(...data: any[]): void; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts new file mode 100644 index 00000000..bfdfad7b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts @@ -0,0 +1,22 @@ +import type { BitcoinAccount } from './account'; + +export type Inscription = { + id: string; + number: number; + contentLength: number; + contentType: string; + satNumber: number; + satRarity: string; + location: string; + imageUrl?: string; +}; + +export type MetaProtocolsClient = { + /** + * Fetch the inscriptions of an account. + * + * @param account - the account to fetch assets from. + * @returns the list of inscriptions + */ + fetchInscriptions(account: BitcoinAccount): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts new file mode 100644 index 00000000..b694ed82 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -0,0 +1,37 @@ +import type { + HistoricalPriceValue, + FungibleAssetMarketData, +} from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; + +export type TimePeriod = 'P1D' | 'P7D' | 'P1M' | 'P3M' | 'P1Y' | 'P1000Y'; + +export type AssetRate = [CaipAssetType, SpotPrice | null]; + +export type SpotPrice = { + price: number; + marketData: FungibleAssetMarketData; +}; + +export type AssetRatesClient = { + /** + * Returns the spot price of an asset relative to an other including market data. + * + * @param vsCurrency - the currency to convert prices to. Defaults to 'usd'. + * @param baseCurrency - the currency to get prices for. Defaults to 'bitcoin'. + */ + spotPrices(vsCurrency?: string, baseCurrency?: string): Promise; + + /** + * Returns a list of historical prices for a token against another. + * + * @param timePeriod - the time period to fetch prices for. + * @param vsCurrency - the currency to convert prices to. Defaults to 'usd'. + * @param baseCurrency - the currency to get prices for. Defaults to 'bitcoin'. + */ + historicalPrices( + timePeriod: TimePeriod, + vsCurrency?: string, + baseCurrency?: string, + ): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts new file mode 100644 index 00000000..bee8546b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -0,0 +1,120 @@ +import type { Network } from '@metamask/bitcoindevkit'; + +import type { CurrencyRate, CurrencyUnit } from './currency'; +import type { CodifiedError } from './error'; + +// TODO: This context will be adjusted to the needs +// of unified send flow. +export type ConfirmSendFormContext = { + from: string; + explorerUrl: string; + network: Network; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + recipient: string; + amount: string; + backgroundEventId?: string; + locale: string; + psbt: string; + origin?: string; + isMine: boolean; +}; + +export type SendFormContext = { + account: { + id: string; + address: string; // FIXME: Address should not be needed to identify an account + }; + network: Network; + balance: string; + feeRate: number; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + recipient?: string; + amount?: string; + fee?: string; + drain?: boolean; + errors: { + tx?: CodifiedError; + recipient?: CodifiedError; + amount?: CodifiedError; + }; + backgroundEventId?: string; + locale: string; +}; + +export enum SendFormEvent { + Amount = 'amount', + Recipient = 'recipient', + ClearRecipient = 'clearRecipient', + ClearAmount = 'clearAmount', + Confirm = 'confirm', + Cancel = 'cancel', + Max = 'max', + Account = 'account', + Asset = 'asset', + SwitchCurrency = 'switchCurrency', +} + +export type ReviewTransactionContext = { + from: string; + explorerUrl: string; + network: Network; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + recipient: string; + amount: string; + backgroundEventId?: string; + locale: string; + psbt: string; + + /** + * Used to repopulate the send form if the user decides to go back in the flow + * Optional when sending directly without form. + */ + sendForm?: SendFormContext; +}; + +export enum ReviewTransactionEvent { + Send = 'send', + HeaderBack = 'headerBack', +} + +/** + * SendFlowRepository is a repository that manages Bitcoin Send flow interfaces. + */ +export type SendFlowRepository = { + /** + * Get the form context. + * + * @param id - the interface ID + * @returns the form context + */ + getContext(id: string): Promise; + + /** + * Insert a new send form interface. + * + * @param context - the form context + * @returns the interface ID + */ + insertForm(context: SendFormContext): Promise; + + /** + * Update an interface to the send form view. + * + * @param id - the interface ID + * @param context - the form context + */ + updateForm(id: string, context: SendFormContext): Promise; + + /** + * Update an interface to the review transaction view. + * + * @param id - the interface ID + * @param context - the review transaction context + */ + updateReview(id: string, context: ReviewTransactionContext): Promise; + + insertConfirmSendForm(context: ConfirmSendFormContext): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts new file mode 100644 index 00000000..e3a545ad --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -0,0 +1,279 @@ +import type { AddressType, Network, WalletTx } from '@metamask/bitcoindevkit'; +import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; +import type { + ComponentOrElement, + GetClientStatusResult, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import type { BitcoinAccount } from './account'; +import type { Inscription } from './meta-protocols'; + +export type SnapState = { + // accountId -> account state. This is the main state of the snap. + accounts: Record; + // derivationPath -> accountId. Only needed for fast lookup. + derivationPaths: Record; +}; + +export type AccountState = { + // Split derivation path. + derivationPath: string[]; + // Wallet data. + wallet: string; + // Wallet inscriptions for meta protocols (ordinals, etc.) + inscriptions: Inscription[]; + // Metadata used by keyring account responses without loading the BDK wallet. + metadata?: AccountMetadata; +}; + +export type AccountMetadata = { + // Public receive address at account address index 0. + address: string; + // Account address type. + addressType: AddressType; + // Bitcoin network. + network: Network; + // Public descriptor for read-only descriptor requests. + publicDescriptor: string; +}; + +export type SyncResult = { + // The synchronized account. + account: BitcoinAccount; + // Transactions that changed and should be notified. + transactionsToNotify: WalletTx[]; +}; + +export enum TrackingSnapEvent { + TransactionFinalized = 'Transaction Finalized', + TransactionReceived = 'Transaction Received', + TransactionReorged = 'Transaction Reorged', + TransactionSubmitted = 'Transaction Submitted', +} + +/** + * The SnapClient represents the MetaMask Snap state and manages the BIP-32 entropy from the Wallet SRP. + */ +export type SnapClient = { + /** + * Get the Snap state for a given key. + * + * @param key - The key to get the state for. Undefined for the root. + * @returns The Snap state. + */ + getState(key?: string): Promise; + + /** + * Set the Snap state for a given key. + * + * @param key - The key to set the state for. Undefined for the root. + * @param newState - The new state. + */ + setState(key?: string, newState?: Json): Promise; + + /** + * Get the private SLIP10 for a given derivation path from the Snap SRP. + * + * @param derivationPath - The derivation path. + * @returns The private SLIP10 node. + */ + getPrivateEntropy(derivationPath: string[]): Promise; + + /** + * Get the public SLIP10 for a given derivation path from the Snap SRP. + * + * @param derivationPath - The derivation path. + * @returns The public SLIP10 node. + */ + getPublicEntropy(derivationPath: string[]): Promise; + + /** + * Emit an event notifying the extension of a newly created Bitcoin account + * + * @param account - The Bitcoin account. + * @param correlationId - The correlation ID to be used for the event. + */ + emitAccountCreatedEvent( + account: BitcoinAccount, + correlationId?: string, + accountName?: string, + ): Promise; + + /** + * Emit an event notifying the extension of a deleted Bitcoin account + * + * @param id - The Bitcoin account id. + */ + emitAccountDeletedEvent(id: string): Promise; + + /** + * Emit an event notifying the extension of updated balances + * + * @param accounts - The Bitcoin accounts to emit balances for. + */ + emitAccountBalancesUpdatedEvent(accounts: BitcoinAccount[]): Promise; + + /** + * Emit an event notifying the extension of updated transactions + * + * @param account - The Bitcoin account. + * @param txs - The transactions included in the event. + */ + emitAccountTransactionsUpdatedEvent( + account: BitcoinAccount, + txs: WalletTx[], + ): Promise; + + /** + * Create a User Interface. + * + * @param ui - The UI Component. + * @param context - The Interface context. + * @returns the interface ID + */ + createInterface( + ui: ComponentOrElement, + context: Record, + ): Promise; + + /** + * Update a User Interface. + * + * @param id - The interface id. + * @param ui - The user interface. + * @param context - The Interface context. + */ + updateInterface( + id: string, + ui: ComponentOrElement, + context: Record, + ): Promise; + + /** + * Display a User Interface. + * + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayInterface(id: string): Promise; + + /** + * Display a Confirmation Dialog. + * + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayConfirmation(id: string): Promise; + + /** + * Display a User Prompt Dialog. + * + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayUserPrompt(id: string): Promise; + + /** + * Resolve a User Interface. + * + * @param id - The interface id. + * @param value - The resolved value. + */ + resolveInterface(id: string, value: Json): Promise; + + /** + * Get the state of an interface. + * + * @param id - The interface id. + * @returns the interface state. + */ + getInterfaceState(id: string): Promise | null>; + + /** + * Get the context of an interface. + * + * @param id - The interface id. + * @returns the interface context. + */ + getInterfaceContext(id: string): Promise | null>; + + /** + * 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. + */ + scheduleBackgroundEvent({ + method, + params, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise; + + /** + * Cancel an already scheduled background event. + * + * @param id - The background event id. + */ + cancelBackgroundEvent(id: string): Promise; + + /** + * Get user preferences. + * + * @returns the user's preferences. + */ + getPreferences(): Promise; + + /** + * Get user's client status. + * + * @returns the user's client status. + */ + getClientStatus(): Promise; + + /** + * Track events that comply with the SIP-32 spec (https://metamask.github.io/SIPs/SIPS/sip-32) + * + * @param eventType The event type we want to track + * @param account The correlated bitcoin account + * @param tx The transaction we want to capture metrics for + * @param origin The origin/source that triggered this event + */ + emitTrackingEvent( + eventType: TrackingSnapEvent, + account: BitcoinAccount, + tx: WalletTx, + origin: string, + ): Promise; + + /** + * Track errors + * + * @param error The error to track + */ + emitTrackingError(error: Error): Promise; + + /** + * Start a performance trace. + * + * @param name - The name of the trace. + * @returns boolean whether the trace was started successfully. + */ + startTrace(name: string): Promise; + + /** + * End a performance trace. + * + * @param name - The name of the trace. + * @returns A promise that resolves when the trace is ended. + */ + endTrace(name: string): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts new file mode 100644 index 00000000..e647523b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -0,0 +1,78 @@ +import type { Amount, Psbt, ScriptBuf } from '@metamask/bitcoindevkit'; + +/** + * A Bitcoin transaction builder. + */ +export type TransactionBuilder = { + /** + * Add a new recipient the PSBT. + * + * @param amount - The amount in satoshis + * @param recipientAddress - The recipient address + */ + addRecipient(amount: string, recipientAddress: string): TransactionBuilder; + + /** + * Add a new recipient the PSBT by script pubkey. + * + * @param amount - The amount in satoshis + * @param recipientScriptPubkey - The recipient script public key + */ + addRecipientByScript( + amount: Amount, + recipientScriptPubkey: ScriptBuf, + ): TransactionBuilder; + + /** + * Set the PSBT fee rate. + * + * @param feeRate - The fee rate in sat/vB + */ + feeRate(feeRate: number): TransactionBuilder; + + /** + * Spend all the available inputs. This respects filters like `unspendable` and the change policy. + */ + drainWallet(): TransactionBuilder; + + /** + * Sets the address to *drain* excess coins to. + * + * Usually, when there are excess coins they are sent to a change address generated by the + * wallet. This option replaces the usual change address with an arbitrary `script_pubkey` of + * your choosing. + * + * @param address - The recipient address + */ + drainTo(address: string): TransactionBuilder; + + /** + * Sets the script to *drain* excess coins to. + * + * Usually, when there are excess coins they are sent to a change address generated by the + * wallet. This option replaces the usual change address with an arbitrary `script_pubkey` of + * your choosing. + * + * @param scriptPubKey - The recipient script + */ + drainToByScript(scriptPubKey: ScriptBuf): TransactionBuilder; + + /** + * Set the list of unspendable UTXOs. These outpoints won't be selected by the coin selection algorithm. + * + * @param unspendable - The list of unspendable UTXO outpoints. + */ + unspendable(unspendable: string[]): TransactionBuilder; + + /** + * Set the ordering for inputs and outputs of the transaction to untouched. + */ + untouchedOrdering(): TransactionBuilder; + + /** + * Creates the PSBT. + * + * @returns the PSBT + */ + finish(): Psbt; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts new file mode 100644 index 00000000..ee1010e9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts @@ -0,0 +1,13 @@ +export type Messages = Record; + +/** + * A Translator. + */ +export type Translator = { + /** + * Load messages given a locale. Defaults to english if the locale is not found. + * + * @param locale - the locale. + */ + load(locale: string): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts new file mode 100644 index 00000000..bee2613a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -0,0 +1,237 @@ +import type { + HistoricalPriceIntervals, + FungibleAssetMarketData, +} from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { AssetsUseCases } from '../use-cases'; +import { AssetsHandler } from './AssetsHandler'; +import { Caip19Asset } from './caip'; +import type { Logger, SnapClient, SpotPrice } from '../entities'; + +describe('AssetsHandler', () => { + const mockAssetsUseCases = mock(); + const mockLogger = mock(); + const expirationInterval = 60; + const mockSnapClient = mock(); + + let handler: AssetsHandler; + + beforeEach(() => { + handler = new AssetsHandler( + mockAssetsUseCases, + expirationInterval, + mockLogger, + mockSnapClient, + ); + }); + + describe('lookup', () => { + it('returns data for all networks', async () => { + const result = await handler.lookup(); + + expect(result.assets[Caip19Asset.Bitcoin]?.name).toBe('Bitcoin'); + expect(result.assets[Caip19Asset.Testnet]?.name).toBe('Testnet Bitcoin'); + expect(result.assets[Caip19Asset.Testnet4]?.name).toBe( + 'Testnet4 Bitcoin', + ); + expect(result.assets[Caip19Asset.Signet]?.name).toBe('Signet Bitcoin'); + expect(result.assets[Caip19Asset.Regtest]?.name).toBe('Regtest Bitcoin'); + }); + }); + + describe('conversion', () => { + it('returns rates for all networks successfully', async () => { + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, mock({ price: 0.1 })], + [Caip19Asset.Regtest, mock({ price: 0.2 })], + ]); + + const conversions = [ + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Regtest }, + { from: Caip19Asset.Testnet, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Testnet4, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, + ]; + const result = await handler.conversion(conversions); + + expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); + expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ + Caip19Asset.Testnet, + Caip19Asset.Regtest, + ]); + expect(result.conversionRates).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: { + rate: '0.1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [Caip19Asset.Regtest]: { + rate: '0.2', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Testnet]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Testnet4]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Signet]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Regtest]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + }); + }); + + it('handles null rates gracefully when getRates returns null', async () => { + const conversions = [ + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, + ]; + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, null], + ]); + + const result = await handler.conversion(conversions); + + expect(result.conversionRates).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: null, + }, + }); + }); + }); + + describe('historicalPrice', () => { + it('returns null if from is not Bitcoin', async () => { + const result = await handler.historicalPrice( + Caip19Asset.Testnet, + Caip19Asset.Bitcoin, + ); + expect(result).toBeNull(); + }); + + it('returns prices for Bitcoin successfully', async () => { + const mockIntervals = mock(); + mockAssetsUseCases.getPriceIntervals.mockResolvedValue(mockIntervals); + + const result = await handler.historicalPrice( + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + ); + + expect(mockAssetsUseCases.getPriceIntervals).toHaveBeenCalledWith( + Caip19Asset.Testnet, + ); + expect(result?.historicalPrice.intervals).toStrictEqual(mockIntervals); + }); + + it('returns null, tracks the error, and logs warning when getPriceIntervals fails', async () => { + const error = new Error('getPriceIntervals failed'); + mockAssetsUseCases.getPriceIntervals.mockRejectedValue(error); + + const result = await handler.historicalPrice( + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + ); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch historical prices from %s to %s. Error: %s', + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + error, + ); + expect(result).toBeNull(); + }); + }); + + describe('marketData', () => { + it('returns market data for all assets successfully', async () => { + const mockMarketData = mock(); + mockAssetsUseCases.getRates.mockResolvedValue([ + [ + Caip19Asset.Testnet, + mock({ price: 0.1, marketData: mockMarketData }), + ], + [ + Caip19Asset.Regtest, + mock({ price: 0.2, marketData: mockMarketData }), + ], + ]); + + const assets = [ + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Regtest }, + { asset: Caip19Asset.Testnet, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Testnet4, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Signet, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Regtest, unit: Caip19Asset.Bitcoin }, + ]; + const result = await handler.marketData(assets); + + expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); + expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ + Caip19Asset.Testnet, + Caip19Asset.Regtest, + ]); + expect(result.marketData).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: mockMarketData, + [Caip19Asset.Regtest]: mockMarketData, + }, + [Caip19Asset.Testnet]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Testnet4]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Signet]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Regtest]: { + [Caip19Asset.Bitcoin]: null, + }, + }); + }); + + it('handles null rates gracefully when getRates returns null', async () => { + const assets = [ + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, + ]; + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, null], + ]); + + const result = await handler.marketData(assets); + + expect(result.marketData).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: null, + }, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts new file mode 100644 index 00000000..50f3ff74 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -0,0 +1,215 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import type { + CaipAssetType, + FungibleAssetMetadata, + OnAssetHistoricalPriceResponse, + OnAssetsConversionArguments, + OnAssetsConversionResponse, + OnAssetsLookupResponse, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, +} from '@metamask/snaps-sdk'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import { assert } from 'superstruct'; + +import type { Logger, SnapClient } from '../entities'; +import type { AssetsUseCases } from '../use-cases'; +import { Caip19Asset } from './caip'; +import { networkToIcon } from './icons'; + +export class AssetsHandler { + readonly #assetsUseCases: AssetsUseCases; + + readonly #expirationInterval: number; + + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + constructor( + assets: AssetsUseCases, + expirationInterval: number, + logger: Logger, + snapClient: SnapClient, + ) { + this.#assetsUseCases = assets; + this.#expirationInterval = expirationInterval; + this.#logger = logger; + this.#snapClient = snapClient; + } + + async lookup(): Promise { + // Static function that cannot fail so no need to use handle() + + const metadata = ( + network: Network, + name: string, + mainSymbol: string, + ): FungibleAssetMetadata => { + return { + fungible: true, + name, + units: [ + { + name: 'Bitcoin', + decimals: 8, + symbol: mainSymbol, + }, + { + name: 'CentiBitcoin', + decimals: 6, + symbol: 'cBTC', + }, + { + name: 'MilliBitcoin', + decimals: 5, + symbol: 'mBTC', + }, + { + name: 'Bit', + decimals: 2, + symbol: 'bits', + }, + { + name: 'Satoshi', + decimals: 0, + symbol: 'satoshi', + }, + ], + iconUrl: networkToIcon[network], + symbol: mainSymbol, + }; + }; + + // Use the same denominations as Bitcoin for testnets but change the name and main unit symbol + return { + assets: { + [Caip19Asset.Bitcoin]: metadata('bitcoin', 'Bitcoin', 'BTC'), + [Caip19Asset.Testnet]: metadata('testnet', 'Testnet Bitcoin', 'tBTC'), + [Caip19Asset.Testnet4]: metadata( + 'testnet4', + 'Testnet4 Bitcoin', + 'tBTC', + ), + [Caip19Asset.Signet]: metadata('signet', 'Signet Bitcoin', 'sBTC'), + [Caip19Asset.Regtest]: metadata('regtest', 'Regtest Bitcoin', 'rBTC'), + }, + }; + } + + async conversion( + conversions: OnAssetsConversionArguments['conversions'], + ): Promise { + const conversionTime = getCurrentUnixTimestamp(); + + // Group conversions by "from" + const assetMap: Record = {}; + for (const { from, to } of conversions) { + assetMap[from] ??= []; + assetMap[from].push(to); + } + + const conversionRates: OnAssetsConversionResponse['conversionRates'] = {}; + + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + const fromKey = fromAsset as keyof typeof conversionRates; + conversionRates[fromKey] = {}; + + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { + // For Bitcoin, fetch rates. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + conversionRates[fromKey][toAsset] = rate + ? { + rate: rate.price.toString(), + conversionTime, + expirationTime: conversionTime + this.#expirationInterval, + } + : null; + } + } else { + // For every other conversions, we just use a rate of 0. + for (const toAsset of toAssets) { + conversionRates[fromKey][toAsset] = { + rate: '0', + conversionTime, + expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests + }; + } + } + } + + return { conversionRates }; + } + + async historicalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + if (from !== (Caip19Asset.Bitcoin as CaipAssetType)) { + return null; + } + + try { + const updateTime = getCurrentUnixTimestamp(); + const intervals = await this.#assetsUseCases.getPriceIntervals(to); + + return { + historicalPrice: { + intervals, + updateTime, + expirationTime: updateTime + this.#expirationInterval, + }, + }; + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + this.#logger.warn( + 'Failed to fetch historical prices from %s to %s. Error: %s', + from, + to, + error, + ); + return null; + } + } + + async marketData( + assets: OnAssetsMarketDataArguments['assets'], + ): Promise { + // Group market data by "asset" + const assetMap: Record = {}; + for (const { asset, unit } of assets) { + assetMap[asset] ??= []; + assetMap[asset].push(unit); + } + + const marketData: OnAssetsMarketDataResponse['marketData'] = {}; + + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + const fromKey = fromAsset as keyof typeof marketData; + marketData[fromKey] = {}; + + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { + // For Bitcoin, fetch market data. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + marketData[fromKey][toAsset] = rate ? rate.marketData : null; + } + } else { + // For every other assets, there is no market data. + for (const toAsset of toAssets) { + marketData[fromKey][toAsset] = null; + } + } + } + + return { marketData }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts new file mode 100644 index 00000000..4a406285 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -0,0 +1,357 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; +import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import { + type BitcoinAccount, + type SnapClient, + type SyncResult, +} from '../entities'; +import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; +import { CronHandler, CronMethod } from './CronHandler'; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + getSelectedAccounts: jest.fn(), +})); + +describe('CronHandler', () => { + const mockSendFlowUseCases = mock(); + const mockAccountUseCases = mock(); + const mockSnapClient = mock(); + const mockSnap = mock(); + + const handler = new CronHandler( + mockAccountUseCases, + mockSendFlowUseCases, + mockSnapClient, + mockSnap, + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockSnapClient.getClientStatus.mockResolvedValue({ + active: true, + locked: false, + clientVersion: '1.0.0', + platformVersion: '1.0.0', + }); + }); + + describe('synchronizeAccounts', () => { + const mockAccount1 = mock({ id: 'account-1' }); + const mockAccount2 = mock({ id: 'account-2' }); + const mockAccounts = [mockAccount1, mockAccount2]; + const request = { method: 'synchronizeAccounts' } as JsonRpcRequest; + + it('synchronizes all selected accounts and emits batched events', async () => { + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); + + await handler.route(request); + + expect(mockSnapClient.getClientStatus).toHaveBeenCalled(); + expect(mockAccountUseCases.list).toHaveBeenCalled(); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( + mockAccounts.length, + ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccounts); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledTimes(1); + }); + + it('emits transaction events for accounts with new transactions', async () => { + const mockTx = mock(); + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [mockTx], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); + + await handler.route(request); + + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount1, [mockTx]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledTimes(1); + }); + + it('propagates errors from list', async () => { + const error = new Error(); + (getSelectedAccounts as jest.Mock).mockResolvedValue(['account-1']); + mockAccountUseCases.list.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', + }); + await handler.route(request); + + expect(mockAccountUseCases.synchronize).not.toHaveBeenCalled(); + }); + + it('throws error if some account fails but still emits for successful ones', async () => { + const mockResult: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult) + .mockRejectedValueOnce(new Error('error')); + + await expect(handler.route(request)).rejects.toThrow( + 'Account synchronization failures', + ); + + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( + mockAccounts.length, + ); + // Should still emit for successful account + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0]]); + }); + }); + + describe('refreshRates', () => { + const request = { + method: CronMethod.RefreshRates, + params: { interfaceId: 'id' }, + } as unknown as JsonRpcRequest; + + it('throws if invalid params', async () => { + await expect( + handler.route({ ...request, params: { invalid: true } }), + ).rejects.toThrow(''); + }); + + it('refreshes the send form rates', async () => { + await handler.route(request); + + expect(mockSendFlowUseCases.refresh).toHaveBeenCalledWith('id'); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', + }); + await handler.route(request); + + expect(mockSendFlowUseCases.refresh).not.toHaveBeenCalled(); + }); + + it('propagates errors from refresh', async () => { + const error = new Error(); + mockSendFlowUseCases.refresh.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + }); + + describe('syncSelectedAccounts', () => { + const mockAccount1 = mock({ id: 'account-1' }); + const mockAccount2 = mock({ id: 'account-2' }); + const mockAccount3 = mock({ id: 'account-3' }); + const mockAccounts = [mockAccount1, mockAccount2, mockAccount3]; + const request = { + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: ['account-1', 'account-2'] }, + } as unknown as JsonRpcRequest; + + it('throws if invalid params', async () => { + await expect( + handler.route({ ...request, params: { invalid: true } }), + ).rejects.toThrow(''); + }); + + it('synchronizes selected accounts and emits batched events', async () => { + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); + + await handler.route(request); + + expect(mockAccountUseCases.list).toHaveBeenCalled(); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledWith( + mockAccounts[0], + 'metamask', + ); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledWith( + mockAccounts[1], + 'metamask', + ); + // Verify batched balance event + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0], mockAccounts[1]]); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledTimes(1); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', + }); + await handler.route(request); + + expect(mockAccountUseCases.synchronize).not.toHaveBeenCalled(); + }); + + it('propagates errors from list', async () => { + const error = new Error(); + mockAccountUseCases.list.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + + it('emits events only for successful accounts when some fail', async () => { + const mockResult: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + const syncError = new Error('scan failed'); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult) + .mockRejectedValueOnce(syncError); + + const result = await handler.route(request); + + expect(result).toBeUndefined(); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SynchronizationError', + message: 'Failed to synchronize 1 selected accounts', + cause: syncError, + }), + ); + + // Should emit for successful account only + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0]]); + }); + }); + + describe('fullScanAccount', () => { + const mockAccount = mock({ id: 'account-1' }); + const request = { + method: CronMethod.FullScanAccount, + params: { accountId: 'account-1' }, + } as unknown as JsonRpcRequest; + + it('throws if invalid params', async () => { + await expect( + handler.route({ ...request, params: { invalid: true } }), + ).rejects.toThrow(''); + }); + + it('performs full scan and emits events', async () => { + const mockTxs = [mock()]; + const mockResult: SyncResult = { + account: mockAccount, + transactionsToNotify: mockTxs, + }; + mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.fullScan.mockResolvedValue(mockResult); + + await handler.route(request); + + expect(mockAccountUseCases.get).toHaveBeenCalledWith('account-1'); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, mockTxs); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', + }); + await handler.route(request); + + expect(mockAccountUseCases.get).not.toHaveBeenCalled(); + expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + }); + + it('propagates errors from get', async () => { + const error = new Error('get failed'); + mockAccountUseCases.get.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + + it('propagates errors from fullScan', async () => { + const error = new Error('fullScan failed'); + mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.fullScan.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts new file mode 100644 index 00000000..658f6a4a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -0,0 +1,194 @@ +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; +import type { JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk'; +import { array, assert, object, string } from 'superstruct'; + +import { + InexistentMethodError, + type SnapClient, + type SyncResult, + SynchronizationError, +} from '../entities'; +import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; + +export enum CronMethod { + SynchronizeAccounts = 'synchronizeAccounts', + RefreshRates = 'refreshRates', + SyncSelectedAccounts = 'syncSelectedAccounts', + FullScanAccount = 'fullScanAccount', +} + +export const SendFormRefreshRatesRequest = object({ + interfaceId: string(), +}); + +export const SyncSelectedAccountsRequest = object({ + accountIds: array(string()), +}); + +export const FullScanAccountRequest = object({ + accountId: string(), +}); + +export class CronHandler { + readonly #accountsUseCases: AccountUseCases; + + readonly #sendFlowUseCases: SendFlowUseCases; + + readonly #snapClient: SnapClient; + + readonly #snap: SnapsProvider; + + constructor( + accounts: AccountUseCases, + sendFlow: SendFlowUseCases, + snapClient: SnapClient, + snap: SnapsProvider, + ) { + this.#accountsUseCases = accounts; + this.#sendFlowUseCases = sendFlow; + this.#snapClient = snapClient; + this.#snap = snap; + } + + async route(request: JsonRpcRequest): Promise { + const { method, params } = request; + + const { active, locked } = await this.#snapClient.getClientStatus(); + if (!active || locked) { + return undefined; + } + + switch (method as CronMethod) { + case CronMethod.SynchronizeAccounts: { + return this.synchronizeAccounts(); + } + case CronMethod.RefreshRates: { + assert(params, SendFormRefreshRatesRequest); + return this.#sendFlowUseCases.refresh(params.interfaceId); + } + case CronMethod.SyncSelectedAccounts: { + assert(params, SyncSelectedAccountsRequest); + return this.syncSelectedAccounts(params.accountIds); + } + case CronMethod.FullScanAccount: { + assert(params, FullScanAccountRequest); + return this.fullScanAccount(params.accountId); + } + default: + throw new InexistentMethodError(`Method not found: ${method}`); + } + } + + async synchronizeAccounts(): Promise { + const selectedAccounts: Set = new Set( + await getSelectedAccounts(this.#snap), + ); + + const accounts = (await this.#accountsUseCases.list()).filter((account) => { + return selectedAccounts.has(account.id); + }); + + const results = await Promise.allSettled( + accounts.map(async (account) => + this.#accountsUseCases.synchronize(account, 'cron'), + ), + ); + + const successfulResults: SyncResult[] = []; + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errors: Record = {}; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { + const id = accounts[index]?.id; + if (id) { + errors[id] = result.reason; + } + } + }); + + await this.#emitSyncEvents(successfulResults); + + if (Object.keys(errors).length > 0) { + throw new SynchronizationError( + 'Account synchronization failures', + errors, + ); + } + } + + async syncSelectedAccounts(accountIds: string[]): Promise { + const accountIdSet = new Set(accountIds); + const allAccounts = await this.#accountsUseCases.list(); + + const selectedAccounts = allAccounts.filter((account) => + accountIdSet.has(account.id), + ); + + const results = await Promise.allSettled( + selectedAccounts.map(async (account) => + this.#accountsUseCases.synchronize(account, 'metamask'), + ), + ); + + const successfulResults = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); + + const rejectedResults = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (rejectedResults.length > 0) { + await this.#snapClient.emitTrackingError( + new SynchronizationError( + `Failed to synchronize ${rejectedResults.length} selected accounts`, + undefined, + rejectedResults[0]?.reason, + ), + ); + } + + await this.#emitSyncEvents(successfulResults); + } + + /** + * Emit batched balance and transaction events for sync results. + * + * @param results - The successful sync results. + */ + async #emitSyncEvents(results: SyncResult[]): Promise { + if (results.length === 0) { + return; + } + + // Emit one batched balance event for all accounts + await this.#snapClient.emitAccountBalancesUpdatedEvent( + results.map((syncResult) => syncResult.account), + ); + + // Emit transaction events per account + for (const { account, transactionsToNotify } of results) { + if (transactionsToNotify.length > 0) { + await this.#snapClient.emitAccountTransactionsUpdatedEvent( + account, + transactionsToNotify, + ); + } + } + } + + async fullScanAccount(accountId: string): Promise { + const account = await this.#accountsUseCases.get(accountId); + const result = await this.#accountsUseCases.fullScan(account); + + await this.#emitSyncEvents([result]); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts new file mode 100644 index 00000000..a7dc68ae --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -0,0 +1,141 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import { + type Logger, + type SnapClient, + type Translator, + BaseError, + ExternalServiceError, + UserActionError, +} from '../entities'; +import { HandlerMiddleware, shouldTrackError } from './HandlerMiddleware'; + +describe('HandlerMiddleware', () => { + const mockLogger = mock(); + const mockSnapClient = mock({ + getPreferences: jest.fn(), + }); + const mockTranslator = mock({ + load: jest.fn(), + }); + + const middleware = new HandlerMiddleware( + mockLogger, + mockSnapClient, + mockTranslator, + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockSnapClient.getPreferences.mockResolvedValue({ + locale: 'en', + } as GetPreferencesResult); + mockTranslator.load.mockResolvedValue({}); + }); + + describe('shouldTrackError', () => { + it('returns false for canceled confirmation errors', () => { + expect( + shouldTrackError( + new UserActionError('User canceled the confirmation'), + mockLogger, + ), + ).toBe(false); + }); + + it('returns true for other errors', () => { + expect(shouldTrackError(new Error('boom'), mockLogger)).toBe(true); + expect( + shouldTrackError( + new UserActionError('Another user action'), + mockLogger, + ), + ).toBe(true); + }); + }); + + describe('handle', () => { + it('executes the function successfully', async () => { + const mockFn = jest.fn().mockResolvedValue('success'); + + const result = await middleware.handle(mockFn); + + expect(result).toBe('success'); + }); + + it('wraps an unexpected Error and preserves its message', async () => { + const error = new Error('boom'); + const mockFn = jest.fn().mockRejectedValue(error); + + await expect(middleware.handle(mockFn)).rejects.toThrow('boom'); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockLogger.error).toHaveBeenCalledWith(error); + }); + + it('tracks an unexpected Error before rethrowing it as a SnapError', async () => { + const error = new Error('tracked boom'); + const mockFn = jest.fn().mockRejectedValue(error); + + await expect(middleware.handle(mockFn)).rejects.toThrow('tracked boom'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); + + it('continues to throw a SnapError when emitTrackingError fails', async () => { + const error = new Error('boom after tracking failure'); + const mockFn = jest.fn().mockRejectedValue(error); + + await expect(middleware.handle(mockFn)).rejects.toThrow(error); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + }); + + it('wraps a non-Error thrown value by stringifying it', async () => { + const mockFn = jest.fn().mockRejectedValue('string failure'); + + await expect(middleware.handle(mockFn)).rejects.toThrow('string failure'); + expect(mockLogger.error).toHaveBeenCalledWith('string failure'); + }); + + it('wraps a thrown plain object by stringifying it', async () => { + const thrown = { foo: 'bar' }; + const mockFn = jest.fn().mockRejectedValue(thrown); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + '[object Object]', + ); + expect(mockLogger.error).toHaveBeenCalledWith(thrown); + }); + + it('handles error successfully if instance of BaseError', async () => { + const error = new BaseError('Test error', 1); + const mockFn = jest.fn().mockRejectedValue(error); + mockTranslator.load.mockResolvedValue({ + 'error.1': { message: 'Test error' }, + }); + + await expect(middleware.handle(mockFn)).rejects.toThrow('Test error'); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockLogger.error).toHaveBeenCalledWith(error, error.data); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); + + it('includes the concrete external service failure in the returned error message', async () => { + const error = new ExternalServiceError('Failed to synchronize account', { + account: 'account-1', + }); + const mockFn = jest.fn().mockRejectedValue(error); + mockTranslator.load.mockResolvedValue({ + 'error.3000': { message: 'Connection error' }, + }); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + 'Connection error: Failed to synchronize account', + ); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts new file mode 100644 index 00000000..6d83d684 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -0,0 +1,145 @@ +import { + DisconnectedError, + InternalError, + InvalidInputError, + InvalidParamsError, + MethodNotFoundError, + ResourceNotFoundError, + UnauthorizedError, + UserRejectedRequestError, + SnapError, +} from '@metamask/snaps-sdk'; +import { StructError } from 'superstruct'; + +import type { Translator, Logger, SnapClient } from '../entities'; +import { + BaseError, + ExternalServiceError, + FormatError, + InexistentMethodError, + NotFoundError, + PermissionError, + StorageError, + UserActionError, + ValidationError, + WalletError, + AssertionError, +} from '../entities'; + +/** + * Determines whether an error should be reported through `snap_trackError`. + * + * @param error - The error to evaluate. + * @param logger - logger for error + * @returns `true` when the error should be tracked. + */ +export function shouldTrackError(error: unknown, logger: Logger): boolean { + try { + return !( + (error as UserActionError)?.message === 'User canceled the confirmation' + ); + } catch { + logger.error(error, 'Failed to determine if error should be tracked'); + return false; + } +} + +export class HandlerMiddleware { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + readonly #translator: Translator; + + constructor(logger: Logger, snapClient: SnapClient, translator: Translator) { + this.#logger = logger; + this.#snapClient = snapClient; + this.#translator = translator; + } + + async handle(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (shouldTrackError(error, this.#logger)) { + await this.#snapClient.emitTrackingError(error as Error); + } + + const { locale } = await this.#snapClient.getPreferences(); + const messages = await this.#translator.load(locale); + + if (error instanceof BaseError) { + this.#logger.error(error, error.data); + + const errMsg = + messages[`error.${error.code}`]?.message ?? + messages['error.internal']?.message ?? + 'Internal error'; + + /* eslint-disable @typescript-eslint/only-throw-error */ + // User errors that he can rectify: Equivalent to 4xx errors + if (error instanceof FormatError) { + throw new InvalidInputError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof ValidationError) { + throw new InvalidParamsError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof NotFoundError) { + throw new ResourceNotFoundError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof InexistentMethodError) { + throw new MethodNotFoundError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof PermissionError) { + throw new UnauthorizedError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof UserActionError) { + throw new UserRejectedRequestError( + `${errMsg}: ${error.message}`, + error.data, + ); + + // Internal errors that we should not expose to the user: Equivalent to 5xx errors + } else if (error instanceof ExternalServiceError) { + throw new DisconnectedError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if ( + error instanceof WalletError || + error instanceof StorageError || + error instanceof AssertionError + ) { + throw new InternalError(errMsg, error.data); + } else { + throw new InternalError(errMsg, error.data); + } + } else { + if (error instanceof StructError) { + const errMsg = messages['error.0']?.message ?? 'Invalid format'; + throw new InvalidInputError( + `${errMsg}: ${error.message}`, + error.data, + ); + } + // Unknown error type — wrap in SnapError to preserve the original + // error's message and class info for observability (previously this + // branch replaced everything with a generic "Unexpected error" + // string, making cross-boundary errors like KeyringControllerError + // opaque in Sentry). + this.#logger.error(error); + throw new SnapError(error instanceof Error ? error : String(error)); + } + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts new file mode 100644 index 00000000..2ca7f1db --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -0,0 +1,1326 @@ +import type { + Amount, + Transaction, + Txid, + TxOut, + Network, + WalletTx, + AddressType, + ScriptBuf, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; +import type { + DiscoveredAccount, + KeyringAccount, + KeyringResponse, + Transaction as KeyringTransaction, + KeyringRequest, +} from '@metamask/keyring-api'; +import { + AccountCreationType, + BtcAccountType, + BtcMethod, + BtcScope, +} from '@metamask/keyring-api'; +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { BitcoinAccount, Logger, SnapClient } from '../entities'; +import { + AccountCapability, + CurrencyUnit, + Purpose, + FormatError, +} from '../entities'; +import { scopeToNetwork, caipToAddressType, Caip19Asset } from './caip'; +import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; +import type { KeyringRequestHandler } from './KeyringRequestHandler'; +import { mapToDiscoveredAccount } from './mappings'; +import type { + AccountUseCases, + CreateAccountParams, +} from '../use-cases/AccountUseCases'; + +jest.mock('superstruct', () => ({ + ...jest.requireActual('superstruct'), + assert: jest.fn(), +})); + +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => { + return { + Address: { + from_script: jest.fn(), + }, + Amount: { + from_sat: (sats: bigint) => ({ + to_btc: () => Number(sats) / 100_000_000, + to_sat: () => sats, + }), + }, + }; +}); + +/** + * Narrows `T | undefined` after `expect(value).toBeDefined()` for use in tests. + * + * @param value - Possibly undefined value. + * @returns The same value narrowed to `T`. + */ +function expectDefined(value: T | undefined): T { + expect(value).toBeDefined(); + return value as T; +} + +describe('KeyringHandler', () => { + const mockKeyringRequest = mock(); + const mockAccounts = mock(); + const mockSnapClient = mock(); + const mockAddress = mock
({ + toString: () => 'bc1qaddress...', + }); + const mockLogger = mock(); + + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + const mockAccount = mock({ + id: 'some-id', + addressType: 'p2wpkh', + balance: { + trusted_spendable: { to_btc: () => 1, to_sat: () => 100_000_000n }, + }, + network: 'bitcoin', + derivationPath: ['myEntropy', "84'", "0'", "0'"], + entropySource: 'myEntropy', + accountIndex: 0, + publicAddress: mockAddress, + capabilities: [AccountCapability.SignPsbt, AccountCapability.ComputeFee], + listUnspent: () => [], + }); + const defaultAddressType: AddressType = 'p2wpkh'; + + const handler = new KeyringHandler( + mockKeyringRequest, + mockAccounts, + defaultAddressType, + mockSnapClient, + mockLogger, + ); + + beforeEach(() => { + mockAccounts.create.mockResolvedValue(mockAccount); + }); + + describe('createAccount', () => { + const entropySource = 'some-source'; + const index = 1; + const correlationId = 'correlation-id'; + + // non-P2WPKH address types as we are not supporting them for v1 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('respects provided params', async () => { + const options = { + scope: BtcScope.Signet, + entropySource, + index, + addressType: BtcAccountType.P2pkh, + metamask: { + correlationId, + }, + accountNameSuggestion: 'My account', + synchronize: false, + }; + const expectedCreateParams: CreateAccountParams = { + network: scopeToNetwork[BtcScope.Signet], + entropySource, + index, + addressType: 'p2pkh', + synchronize: false, + correlationId, + accountName: 'My account', + }; + + await handler.createAccount(options); + + expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + expect(mockAccounts.fullScan).not.toHaveBeenCalled(); + }); + + // only P2WPKH (BIP-84) derivation paths are now supported for v1 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('extracts index from derivationPath', async () => { + const options = { + scope: BtcScope.Signet, + derivationPath: "m/44'/0'/5'/*/*", // change and address indexes can be anything + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 5, + addressType: 'p2pkh', + entropySource: 'm', + synchronize: true, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + + // Test with a valid derivationPath without change and address index + await handler.createAccount({ + ...options, + derivationPath: "m/44'/0'/3'", + }); + expect(mockAccounts.create).toHaveBeenCalledWith({ + ...expectedCreateParams, + index: 3, + }); + }); + + it('auto increment index', async () => { + // We should get index 1 + mockAccounts.list.mockResolvedValue([ + mock({ + entropySource: 'entropy1', + accountIndex: 1, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 2, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 0, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 3, + addressType: 'p2tr', + network: 'bitcoin', + }), + ]); + + const options = { + scope: BtcScope.Signet, + index: null, + entropySource: 'entropy2', + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 1, + addressType: 'p2wpkh', + entropySource: 'entropy2', + synchronize: false, + }; + + await handler.createAccount(options); + + expect(mockAccounts.list).toHaveBeenCalled(); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }); + + it.each([{ purpose: Purpose.NativeSegwit, addressType: 'p2wpkh' }] as { + purpose: Purpose; + addressType: AddressType; + }[])( + 'extracts P2WPKH address type from derivationPath: %s', + async ({ purpose, addressType }) => { + const options = { + scope: BtcScope.Signet, + derivationPath: `m/${purpose}'/0'/0'`, + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 0, + addressType, + entropySource: 'm', + synchronize: false, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }, + ); + + // skip non-P2WPKH address types as they are not supported on v1 + // eslint-disable-next-line jest/no-disabled-tests + it.skip.each([ + { purpose: Purpose.Legacy, addressType: 'p2pkh' }, + { purpose: Purpose.Segwit, addressType: 'p2sh' }, + { purpose: Purpose.Taproot, addressType: 'p2tr' }, + { purpose: Purpose.Multisig, addressType: 'p2wsh' }, + ] as { purpose: Purpose; addressType: AddressType }[])( + 'extracts address type from derivationPath: %s', + async ({ purpose, addressType }) => { + const options = { + scope: BtcScope.Signet, + derivationPath: `m/${purpose}'/0'/0'`, + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 0, + addressType, + entropySource: 'm', + synchronize: true, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }, + ); + + it('fails if derivationPath is invalid', async () => { + const options = { + scope: BtcScope.Signet, + derivationPath: "m/44'/0'/NaN'", + }; + + await expect(handler.createAccount(options)).rejects.toThrow( + 'Invalid account index: NaN', + ); + + await expect( + handler.createAccount({ ...options, derivationPath: "m/60'/0'/0'" }), // unknown purpose + ).rejects.toThrow('Invalid BIP-purpose: 60'); + + await expect( + handler.createAccount({ ...options, derivationPath: "m/44'/0'/-1'" }), // negative index + ).rejects.toThrow("Invalid account index: -1'"); + + await expect( + handler.createAccount({ ...options, derivationPath: "m/44'" }), // missing segments + ).rejects.toThrow("Invalid derivation path: m/44'"); + }); + + it('fails when addressType and derivationPath mismatch', async () => { + const options = { + scope: BtcScope.Signet, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/44'/0'/0'", // Legacy path (P2PKH) + }; + + // The error comes from #extractAddressType which validates the derivation path first + await expect(handler.createAccount(options)).rejects.toThrow( + new FormatError( + 'Only native segwit (BIP-84) derivation paths are supported', + ), + ); + }); + + it('succeeds when addressType and derivationPath both indicate P2WPKH', async () => { + const options = { + scope: BtcScope.Signet, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/0'/5'", // Native segwit path + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 5, + addressType: 'p2wpkh', + entropySource: 'm', + synchronize: false, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }); + + it('propagates errors from createAccount', async () => { + const error = new Error('createAccount error'); + mockAccounts.create.mockRejectedValue(error); + + await expect( + handler.createAccount({ scopes: [BtcScope.Mainnet], index: 0 }), + ).rejects.toThrow(error); + expect(mockAccounts.create).toHaveBeenCalled(); + }); + + describe('tracing', () => { + const options = { + scope: BtcScope.Mainnet, + index: 0, + }; + + beforeEach(() => { + mockSnapClient.startTrace.mockResolvedValue(true); + mockSnapClient.endTrace.mockResolvedValue(undefined); + }); + + it('calls startTrace and endTrace with correct trace name', async () => { + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + }); + + it('calls startTrace before creating account', async () => { + const callOrder: string[] = []; + mockSnapClient.startTrace.mockImplementation(async () => { + callOrder.push('startTrace'); + return true; + }); + mockAccounts.create.mockImplementation(async () => { + callOrder.push('createAccount'); + return mockAccount; + }); + + await handler.createAccount(options); + + expect(callOrder).toStrictEqual(['startTrace', 'createAccount']); + }); + + it('calls endTrace after creating account', async () => { + const callOrder: string[] = []; + mockAccounts.create.mockImplementation(async () => { + callOrder.push('createAccount'); + return mockAccount; + }); + mockSnapClient.endTrace.mockImplementation(async () => { + callOrder.push('endTrace'); + }); + + await handler.createAccount(options); + + expect(callOrder).toStrictEqual(['createAccount', 'endTrace']); + }); + + it('creates account even if startTrace fails', async () => { + mockSnapClient.startTrace.mockResolvedValue(false); + + const result = await handler.createAccount(options); + + expect(result).toBeDefined(); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockSnapClient.startTrace).toHaveBeenCalled(); + }); + + it('calls endTrace when startTrace returns true', async () => { + mockSnapClient.startTrace.mockResolvedValue(true); + + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + }); + + it('does not call endTrace when startTrace returns false', async () => { + mockSnapClient.startTrace.mockResolvedValue(false); + + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).not.toHaveBeenCalled(); + }); + }); + }); + + describe('createAccounts', () => { + const entropySource = 'some-source'; + + const mnemonicGroupIndex = (account: KeyringAccount): number => { + const { entropy } = account.options; + if (entropy?.type !== 'mnemonic') { + throw new Error('expected mnemonic entropy'); + } + return entropy.groupIndex; + }; + + const buildMockAccount = (index: number): BitcoinAccount => + mock({ + id: `id-${index}`, + addressType: 'p2wpkh', + balance: { trusted_spendable: { to_btc: () => 1 } }, + network: 'bitcoin', + derivationPath: ['myEntropy', "84'", "0'", `${index}'`], + entropySource: 'myEntropy', + accountIndex: index, + publicAddress: mockAddress, + capabilities: [ + AccountCapability.SignPsbt, + AccountCapability.ComputeFee, + ], + }); + + it('creates a single account for Bip44DeriveIndex', async () => { + const bitcoinAccount = buildMockAccount(2); + mockAccounts.createMany.mockResolvedValue([bitcoinAccount]); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 2, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith([ + { + network: 'bitcoin', + entropySource, + index: 2, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + expect(result).toHaveLength(1); + const keyringAccount = expectDefined(result[0]); + expect(keyringAccount.id).toBe('id-2'); + expect(mnemonicGroupIndex(keyringAccount)).toBe(2); + }); + + it('creates an inclusive range of accounts for Bip44DeriveIndexRange', async () => { + mockAccounts.createMany.mockResolvedValue( + [0, 1, 2].map(buildMockAccount), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 2 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith( + [0, 1, 2].map((index) => ({ + network: 'bitcoin', + entropySource, + index, + addressType: 'p2wpkh', + synchronize: false, + })), + ); + expect(result).toHaveLength(3); + expect(result.map((a) => mnemonicGroupIndex(a))).toStrictEqual([0, 1, 2]); + }); + + it('creates one account when range from and to are equal', async () => { + mockAccounts.createMany.mockResolvedValue([buildMockAccount(9)]); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 9, to: 9 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith([ + expect.objectContaining({ index: 9 }), + ]); + expect(result).toHaveLength(1); + expect(mnemonicGroupIndex(expectDefined(result[0]))).toBe(9); + }); + + it('creates accounts for a non-zero index range', async () => { + mockAccounts.createMany.mockResolvedValue( + [10, 11, 12].map(buildMockAccount), + ); + + await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 10, to: 12 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledWith( + [10, 11, 12].map((index) => expect.objectContaining({ index })), + ); + }); + + it('allows the maximum batch size of 100 accounts in one RPC', async () => { + const indices = Array.from({ length: 100 }, (_, index) => index); + mockAccounts.createMany.mockResolvedValue(indices.map(buildMockAccount)); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 99 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith( + indices.map((index) => expect.objectContaining({ index })), + ); + expect(result).toHaveLength(100); + expect(mnemonicGroupIndex(expectDefined(result[0]))).toBe(0); + expect(mnemonicGroupIndex(expectDefined(result[99]))).toBe(99); + }); + + it('returns accounts in the order returned by createMany', async () => { + mockAccounts.createMany.mockResolvedValue( + [0, 1, 2, 3, 4].map(buildMockAccount), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 4 }, + entropySource, + }); + + expect(result.map((a) => mnemonicGroupIndex(a))).toStrictEqual([ + 0, 1, 2, 3, 4, + ]); + }); + + it('rejects when the handler default address type is not P2WPKH', async () => { + const handlerNonSegwit = new KeyringHandler( + mockKeyringRequest, + mockAccounts, + 'p2tr' as AddressType, + mockSnapClient, + mockLogger, + ); + + await expect( + handlerNonSegwit.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 0, + entropySource, + }), + ).rejects.toThrow( + /Only native segwit \(P2WPKH\) addresses are supported/iu, + ); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('rejects an invalid index range when from is greater than to', async () => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 2, to: 0 }, + entropySource, + }), + ).rejects.toThrow(/invalid.*range|from must be/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it.each([ + { from: -1, to: 0 }, + { from: 0.5, to: 1 }, + { from: 0, to: Number.MAX_SAFE_INTEGER + 1 }, + ])('rejects invalid account index bounds %#', async (range) => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range, + entropySource, + }), + ).rejects.toThrow(/non-negative integers/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('splits requests larger than 100 accounts into internal batches', async () => { + mockAccounts.createMany.mockImplementation(async (requests) => + requests.map(({ index }) => buildMockAccount(index)), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 100 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(2); + expect(mockAccounts.createMany).toHaveBeenNthCalledWith( + 1, + Array.from({ length: 100 }, (_, index) => + expect.objectContaining({ index }), + ), + ); + expect(mockAccounts.createMany).toHaveBeenNthCalledWith(2, [ + expect.objectContaining({ index: 100 }), + ]); + expect(result).toHaveLength(101); + expect( + result.map((account) => mnemonicGroupIndex(account)), + ).toStrictEqual(Array.from({ length: 101 }, (_, index) => index)); + }); + + it('rejects unsupported creation types', async () => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DerivePath, + derivationPath: "m/84'/0'/0'", + entropySource, + }), + ).rejects.toThrow(/not supported|unsupported/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('propagates errors from createMany', async () => { + const error = new Error('create error'); + mockAccounts.createMany.mockRejectedValue(error); + + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 0, + entropySource, + }), + ).rejects.toThrow(error); + }); + + describe('tracing', () => { + const options = { + type: AccountCreationType.Bip44DeriveIndex as const, + groupIndex: 0, + entropySource, + }; + + beforeEach(() => { + mockSnapClient.startTrace.mockResolvedValue(true); + mockSnapClient.endTrace.mockResolvedValue(undefined); + mockAccounts.createMany.mockResolvedValue([buildMockAccount(0)]); + }); + + it('calls startTrace and endTrace with correct trace name', async () => { + await handler.createAccounts(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + }); + + it('calls endTrace even if create fails', async () => { + mockAccounts.createMany.mockRejectedValue(new Error('boom')); + + await expect(handler.createAccounts(options)).rejects.toThrow('boom'); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + }); + + it('does not call endTrace when startTrace returns false', async () => { + mockSnapClient.startTrace.mockResolvedValue(false); + + await handler.createAccounts(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + expect(mockSnapClient.endTrace).not.toHaveBeenCalled(); + }); + }); + }); + + describe('discoverAccounts', () => { + const entropySource = 'some-source'; + const groupIndex = 0; + const scopes = Object.values(BtcScope); + + it('creates, scans and returns accounts for every scope/addressType combination', async () => { + // only P2WPKH is now supported for v1 + const addressTypes = [BtcAccountType.P2wpkh]; + const totalCombinations = scopes.length * addressTypes.length; + + const expected: DiscoveredAccount[] = []; + scopes.forEach((scope) => { + addressTypes.forEach((addrType) => { + const acc = mock({ + addressType: caipToAddressType[addrType], + network: scopeToNetwork[scope], + listTransactions: jest.fn().mockReturnValue([{}]), // has history + derivationPath: ['m', "84'", "0'", "0'"], + }); + + expected.push(mapToDiscoveredAccount(acc)); + mockAccounts.discover.mockResolvedValueOnce(acc); + }); + }); + + const discovered = await handler.discoverAccounts( + scopes, + entropySource, + groupIndex, + ); + + expect(mockAccounts.discover).toHaveBeenCalledTimes(totalCombinations); + + // validate each individual create() call arguments + scopes.forEach((scope, sIdx) => { + addressTypes.forEach((addrType, aIdx) => { + const callIdx = sIdx * addressTypes.length + aIdx; + expect(mockAccounts.discover).toHaveBeenNthCalledWith(callIdx + 1, { + network: scopeToNetwork[scope], + entropySource, + index: groupIndex, + addressType: caipToAddressType[addrType], + }); + }); + }); + + // Order is not guaranteed, so compare as sets + expect(discovered).toHaveLength(expected.length); + expect(discovered).toStrictEqual(expect.arrayContaining(expected)); + }); + + it('returns mix of accounts with and without history, filtering correctly', async () => { + // create mock accounts - some with history, some without + const accountWithHistory1 = mock({ + addressType: 'p2wpkh', + network: 'bitcoin', + listTransactions: jest.fn().mockReturnValue([{}, {}]), // has 2 transactions + derivationPath: ['m', "84'", "0'", "0'"], + }); + + const accountWithoutHistory = mock({ + addressType: 'p2wpkh', + network: 'testnet', + listTransactions: jest.fn().mockReturnValue([]), // no history + derivationPath: ['m', "84'", "1'", "0'"], + }); + + const accountWithHistory2 = mock({ + addressType: 'p2wpkh', + network: 'signet', + listTransactions: jest.fn().mockReturnValue([{}]), // has 1 transaction + derivationPath: ['m', "84'", "1'", "0'"], + }); + + mockAccounts.discover + .mockResolvedValueOnce(accountWithHistory1) + .mockResolvedValueOnce(accountWithoutHistory) + .mockResolvedValueOnce(accountWithHistory2); + + const discovered = await handler.discoverAccounts( + [BtcScope.Mainnet, BtcScope.Testnet, BtcScope.Signet], + entropySource, + groupIndex, + ); + + expect(mockAccounts.discover).toHaveBeenCalledTimes(3); + expect(discovered).toHaveLength(2); + expect(discovered).toStrictEqual([ + mapToDiscoveredAccount(accountWithHistory1), + mapToDiscoveredAccount(accountWithHistory2), + ]); + }); + + it('propagates errors from discover', async () => { + const error = new Error('discover error'); + mockAccounts.discover.mockRejectedValue(error); + + await expect( + handler.discoverAccounts(scopes, entropySource, groupIndex), + ).rejects.toThrow(error); + expect(mockAccounts.discover).toHaveBeenCalled(); + }); + }); + + describe('getAccountBalances', () => { + it('gets the account balance', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + const expectedResponse = { + [Caip19Asset.Bitcoin]: { + amount: '1', + unit: 'BTC', + }, + }; + + const result = await handler.getAccountBalances(mockAccount.id); + expect(mockAccounts.get).toHaveBeenCalledWith(mockAccount.id); + expect(result).toStrictEqual(expectedResponse); + }); + + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect(handler.getAccountBalances(mockAccount.id)).rejects.toThrow( + error, + ); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('getAccount', () => { + it('gets account', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + const expectedKeyringAccount = { + id: 'some-id', + type: BtcAccountType.P2wpkh, + scopes: [BtcScope.Mainnet], + address: 'bc1qaddress...', + options: { + entropySource: 'myEntropy', + entropy: { + derivationPath: "m/84'/0'/0'", + groupIndex: 0, + id: 'myEntropy', + type: 'mnemonic', + }, + exportable: false, + }, + methods: mockAccount.capabilities, + }; + + const result = await handler.getAccount('some-id'); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual(expectedKeyringAccount); + }); + + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect(handler.getAccount('some-id')).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('listAccounts', () => { + it('lists accounts', async () => { + mockAccounts.list.mockResolvedValue([mockAccount]); + const expectedKeyringAccounts = [ + { + id: 'some-id', + type: BtcAccountType.P2wpkh, + scopes: [BtcScope.Mainnet], + address: 'bc1qaddress...', + options: { + entropySource: 'myEntropy', + entropy: { + derivationPath: "m/84'/0'/0'", + groupIndex: 0, + id: 'myEntropy', + type: 'mnemonic', + }, + exportable: false, + }, + methods: mockAccount.capabilities, + }, + ]; + + const result = await handler.listAccounts(); + expect(mockAccounts.list).toHaveBeenCalled(); + expect(result).toStrictEqual(expectedKeyringAccounts); + }); + + it('propagates errors from list', async () => { + const error = new Error(); + mockAccounts.list.mockRejectedValue(error); + + await expect(handler.listAccounts()).rejects.toThrow(error); + expect(mockAccounts.list).toHaveBeenCalled(); + }); + }); + + describe('filterAccountChains', () => { + it('includes chain if account network corresponds', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + + const result = await handler.filterAccountChains('some-id', [ + BtcScope.Mainnet, + ]); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([BtcScope.Mainnet]); + }); + + it('does not include chain if account network does not correspond', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + + const result = await handler.filterAccountChains('some-id', [ + BtcScope.Testnet, + ]); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([]); + }); + + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect( + handler.filterAccountChains('some-id', [BtcScope.Mainnet]), + ).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('deleteAccount', () => { + it('deletes account', async () => { + await handler.deleteAccount('some-id'); + expect(mockAccounts.delete).toHaveBeenCalledWith('some-id'); + }); + + it('propagates errors from delete', async () => { + const error = new Error(); + mockAccounts.delete.mockRejectedValue(error); + + await expect(handler.deleteAccount('some-id')).rejects.toThrow(error); + expect(mockAccounts.delete).toHaveBeenCalled(); + }); + }); + + describe('listAccountAssets', () => { + it.each([ + { tNetwork: 'bitcoin', caip19: Caip19Asset.Bitcoin }, + { tNetwork: 'testnet', caip19: Caip19Asset.Testnet }, + { tNetwork: 'testnet4', caip19: Caip19Asset.Testnet4 }, + { tNetwork: 'signet', caip19: Caip19Asset.Signet }, + { tNetwork: 'regtest', caip19: Caip19Asset.Regtest }, + ] as { tNetwork: Network; caip19: Caip19Asset }[])( + 'list assets for account: %s', + async ({ tNetwork, caip19 }) => { + mockAccounts.get.mockResolvedValue({ + network: tNetwork, + } as unknown as BitcoinAccount); + + const result = await handler.listAccountAssets('some-id'); + + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([caip19]); + }, + ); + + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect(handler.listAccountAssets('some-id')).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('listAccountTransactions', () => { + const pagination = { limit: 10, next: null }; + + const mockAmount = mock({ + to_btc: () => 21, + }); + const mockScriptPubkey = mock({ + is_op_return: () => false, + }); + const mockOutput = mock({ + value: mockAmount, + script_pubkey: mockScriptPubkey, + }); + const mockTxid = mock({ + toString: () => 'txid', + }); + const mockTx = mock({ + output: [mockOutput], + }); + const mockWalletTx = mock({ + tx: mockTx, + txid: mockTxid, + chain_position: { + last_seen: BigInt(12345), + anchor: { + confirmation_time: BigInt(4567), + }, + }, + }); + + const expectedResult: KeyringTransaction = { + account: mockAccount.id, + chain: BtcScope.Mainnet, + id: 'txid', + type: 'send', + status: 'confirmed', + timestamp: 4567, + events: [ + { + status: 'unconfirmed', + timestamp: 12345, + }, + { + status: 'confirmed', + timestamp: 4567, + }, + ], + fees: [ + { + type: 'priority', + asset: { + amount: '21', + fungible: true, + type: Caip19Asset.Bitcoin, + unit: CurrencyUnit.Bitcoin, + }, + }, + ], + from: [], + to: [ + { + address: 'bc1qaddress...', + asset: { + amount: '21', + fungible: true, + type: Caip19Asset.Bitcoin, + unit: CurrencyUnit.Bitcoin, + }, + }, + ], + }; + + beforeEach(() => { + mockAccount.calculateFee.mockReturnValue(mockAmount); + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccount.sentAndReceived.mockReturnValue([mockAmount, mockAmount]); + mockAccount.listTransactions.mockReturnValue([mockWalletTx]); + (Address.from_script as jest.Mock).mockReturnValue(mockAddress); + }); + + it('lists transactions successfully: send', async () => { + const id = 'some-id'; + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([expectedResult]); + }); + + it('discards own outputs from send transactions', async () => { + const id = 'some-id'; + mockAccount.isMine.mockReturnValueOnce(true); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([{ ...expectedResult, to: [] }]); + }); + + it('lists transactions successfully: receive', async () => { + const id = 'some-id'; + + mockAccount.sentAndReceived.mockReturnValueOnce([ + { ...mockAmount, to_btc: () => 0 }, + mockAmount, + ]); + mockAccount.isMine.mockReturnValueOnce(true); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([ + { ...expectedResult, type: 'receive', fees: [] }, + ]); + }); + + it('respects limit and sets next to last txid', async () => { + const id = 'some-id'; + const mockTransactions = Array.from({ length: 12 }, (_, index) => ({ + ...mockWalletTx, + txid: mock({ + toString: () => `txid-${index}`, + }), + })); + + mockAccount.listTransactions.mockReturnValue(mockTransactions); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(result.data).toHaveLength(pagination.limit); + expect(result.next).toBe('txid-9'); + }); + + it('applies next parameter until last element', async () => { + const id = 'some-id'; + const mockTransactions = Array.from({ length: 12 }, (_, index) => ({ + ...mockWalletTx, + txid: mock({ + toString: () => `txid-${index}`, + }), + })); + + mockAccount.listTransactions.mockReturnValue(mockTransactions); + + const result = await handler.listAccountTransactions(id, { + ...pagination, + next: 'txid-9', + }); + + expect(result.data).toHaveLength( + mockTransactions.length - pagination.limit, + ); + expect(result.next).toBeNull(); + }); + }); + + describe('updateAccount', () => { + it('throws not supported', async () => { + await expect(handler.updateAccount()).rejects.toThrow( + 'Method not supported.', + ); + }); + }); + + describe('submitRequest', () => { + it('calls KeyringRequestHandler', async () => { + const mockRequest = mock(); + const expectedResponse = mock(); + mockKeyringRequest.route.mockResolvedValue(expectedResponse); + + const result = await handler.submitRequest(mockRequest); + + expect(mockKeyringRequest.route).toHaveBeenCalledWith(mockRequest); + expect(result).toStrictEqual(expectedResponse); + }); + }); + + describe('resolveAccountAddress', () => { + const mockKeyringAccount1 = mock({ + id: 'account-1', + address: 'test123', + scopes: [BtcScope.Regtest], + }); + const mockKeyringAccount2 = mock({ + id: 'account-2', + address: 'test456', + scopes: [BtcScope.Regtest], + }); + + beforeEach(() => { + mockAccounts.list.mockResolvedValue([mockAccount]); + }); + + it('resolves account address successfully', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValueOnce([mockKeyringAccount1, mockKeyringAccount2]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(handler.listAccounts).toHaveBeenCalled(); + expect(result).toStrictEqual({ + address: `${BtcScope.Regtest}:test123`, + }); + }); + + it('returns null when account address not found', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'notfound' }, + psbt: 'psbt', + }, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null when no accounts match the scope', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + + const accountWithDifferentScope = mock({ + id: 'account-3', + address: 'test123', + scopes: [BtcScope.Mainnet], + }); + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([accountWithDifferentScope]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null and tracks the error when scope validation fails', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + const error = new Error('Invalid scope'); + + jest.mocked(assert).mockImplementationOnce(() => { + throw error; + }); + + const result = await handler.resolveAccountAddress( + 'invalid-scope' as never, + request, + ); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(result).toBeNull(); + }); + + it('returns null when request validation fails', async () => { + const invalidRequest = { + id: '1', + jsonrpc: '2.0' as const, + method: 'invalid', + params: {}, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + + // First assert (scope) passes, second assert (request struct) throws + jest + .mocked(assert) + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error('Invalid request'); + }); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + invalidRequest, + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts new file mode 100644 index 00000000..7c4823b4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -0,0 +1,624 @@ +import type { AddressType } from '@metamask/bitcoindevkit'; +import { Amount } from '@metamask/bitcoindevkit'; +import { + AccountCreationType, + assertCreateAccountOptionIsSupported, + BtcAccountType, + BtcScope, + CreateAccountRequestStruct, + CreateAccountsRequestStruct, + DeleteAccountRequestStruct, + DiscoverAccountsRequestStruct, + FilterAccountChainsStruct, + GetAccountBalancesRequestStruct, + GetAccountRequestStruct, + KeyringRpcMethod, + ListAccountAssetsRequestStruct, + ListAccountsRequestStruct, + ListAccountTransactionsRequestStruct, + MetaMaskOptionsStruct, + ResolveAccountAddressRequestStruct, + SetSelectedAccountsRequestStruct, + SubmitRequestRequestStruct, +} from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + Keyring, + KeyringAccount, + KeyringResponse, + Balance, + CaipAssetType, + CaipAssetTypeOrId, + Paginated, + Transaction, + Pagination, + MetaMaskOptions, + DiscoveredAccount, + KeyringRequest, + ResolvedAccountAddress, +} from '@metamask/keyring-api'; +import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { + assert, + boolean, + enums, + number, + object, + optional, + string, +} from 'superstruct'; + +import { + type BitcoinAccount, + computeDisplayBalanceSats, + FormatError, + InexistentMethodError, + type Logger, + networkToCurrencyUnit, + Purpose, + purposeToAddressType, + type SnapClient, +} from '../entities'; +import { + networkToCaip19, + caipToAddressType, + scopeToNetwork, + networkToScope, + NetworkStruct, +} from './caip'; +import { CronMethod } from './CronHandler'; +import type { KeyringRequestHandler } from './KeyringRequestHandler'; +import { + mapToDiscoveredAccount, + mapToKeyringAccount, + mapToTransaction, +} from './mappings'; +import { BtcWalletRequestStruct, validateSelectedAccounts } from './validation'; +import type { + AccountUseCases, + CreateAccountParams, +} from '../use-cases/AccountUseCases'; + +export const CreateAccountRequest = object({ + scope: enums(Object.values(BtcScope)), + addressType: optional(enums(Object.values(BtcAccountType))), + entropySource: optional(string()), + accountNameSuggestion: optional(string()), + synchronize: optional(boolean()), + index: optional(number()), + derivationPath: optional(string()), + ...MetaMaskOptionsStruct.schema, +}); + +/** Maximum number of accounts to create in one internal createMany call. */ +const MAX_CREATE_ACCOUNTS_PER_BATCH = 100; + +export class KeyringHandler implements Keyring { + readonly #accountsUseCases: AccountUseCases; + + readonly #keyringRequest: KeyringRequestHandler; + + readonly #defaultAddressType: AddressType; + + readonly #snapClient: SnapClient; + + readonly #logger: Logger; + + constructor( + keyringRequest: KeyringRequestHandler, + accounts: AccountUseCases, + defaultAddressType: AddressType, + snapClient: SnapClient, + logger: Logger, + ) { + this.#keyringRequest = keyringRequest; + this.#accountsUseCases = accounts; + this.#defaultAddressType = defaultAddressType; + this.#snapClient = snapClient; + this.#logger = logger; + } + + async route(request: JsonRpcRequest): Promise { + switch (request.method) { + case `${KeyringRpcMethod.ListAccounts}`: { + assert(request, ListAccountsRequestStruct); + return this.listAccounts(); + } + case `${KeyringRpcMethod.GetAccount}`: { + assert(request, GetAccountRequestStruct); + return this.getAccount(request.params.id); + } + case `${KeyringRpcMethod.CreateAccount}`: { + assert(request, CreateAccountRequestStruct); + return this.createAccount(request.params.options); + } + case `${KeyringRpcMethod.CreateAccounts}`: { + assert(request, CreateAccountsRequestStruct); + return this.createAccounts(request.params.options); + } + case `${KeyringRpcMethod.DiscoverAccounts}`: { + assert(request, DiscoverAccountsRequestStruct); + return this.discoverAccounts( + request.params.scopes as BtcScope[], + request.params.entropySource, + request.params.groupIndex, + ); + } + case `${KeyringRpcMethod.ListAccountTransactions}`: { + assert(request, ListAccountTransactionsRequestStruct); + return this.listAccountTransactions( + request.params.id, + request.params.pagination, + ); + } + case `${KeyringRpcMethod.ListAccountAssets}`: { + assert(request, ListAccountAssetsRequestStruct); + return this.listAccountAssets(request.params.id); + } + case `${KeyringRpcMethod.GetAccountBalances}`: { + assert(request, GetAccountBalancesRequestStruct); + return this.getAccountBalances(request.params.id); + } + case `${KeyringRpcMethod.FilterAccountChains}`: { + assert(request, FilterAccountChainsStruct); + return this.filterAccountChains( + request.params.id, + request.params.chains, + ); + } + case `${KeyringRpcMethod.DeleteAccount}`: { + assert(request, DeleteAccountRequestStruct); + await this.deleteAccount(request.params.id); + return null; + } + case `${KeyringRpcMethod.SubmitRequest}`: { + assert(request, SubmitRequestRequestStruct); + return this.submitRequest(request.params); + } + case `${KeyringRpcMethod.SetSelectedAccounts}`: { + assert(request, SetSelectedAccountsRequestStruct); + await this.setSelectedAccounts(request.params.accounts); + return null; + } + case `${KeyringRpcMethod.ResolveAccountAddress}`: { + assert(request, ResolveAccountAddressRequestStruct); + return this.resolveAccountAddress( + request.params.scope, + request.params.request, + ); + } + + default: { + throw new InexistentMethodError('Keyring method not supported', { + method: request.method, + }); + } + } + } + + async listAccounts(): Promise { + const accounts = await this.#accountsUseCases.list(); + return accounts.map(mapToKeyringAccount); + } + + async getAccount(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return mapToKeyringAccount(account); + } + + async createAccount( + options: Record & MetaMaskOptions, + ): Promise { + assert(options, CreateAccountRequest); + + const traceName = 'Create Bitcoin Account'; + const traceStarted = await this.#snapClient.startTrace(traceName); + + try { + const { + metamask, + scope, + entropySource = 'm', + index, + derivationPath, + addressType, + synchronize = false, + accountNameSuggestion, + } = options; + + let resolvedIndex = derivationPath + ? this.#extractAccountIndex(derivationPath) + : index; + + let resolvedAddressType: AddressType; + if (addressType) { + // only support P2WPKH addresses for v1 + if (addressType !== BtcAccountType.P2wpkh) { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } + resolvedAddressType = caipToAddressType[addressType]; + + // if both addressType and derivationPath are provided, validate they match + if (derivationPath) { + const pathAddressType = this.#extractAddressType(derivationPath); + if (pathAddressType !== resolvedAddressType) { + throw new FormatError('Address type and derivation path mismatch'); + } + } + } else if (derivationPath) { + resolvedAddressType = this.#extractAddressType(derivationPath); + } else { + resolvedAddressType = this.#defaultAddressType; + // validate default address type is P2WPKH just to be sure + if (resolvedAddressType !== 'p2wpkh') { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } + } + + // FIXME: This if should be removed ASAP as the index should always be defined or be 0 + // The Snap automatically increasing the index per request creates significant issues + // such as: concurrency, lack of idempotency, dangling state (if MM crashes before saving the account), etc. + if (resolvedIndex === undefined || resolvedIndex === null) { + const accounts = (await this.#accountsUseCases.list()).filter( + (acc) => + acc.entropySource === entropySource && + acc.network === scopeToNetwork[scope] && + acc.addressType === resolvedAddressType, + ); + + resolvedIndex = this.#getLowestUnusedIndex(accounts); + } + + const account = await this.#accountsUseCases.create({ + network: scopeToNetwork[scope], + entropySource, + index: resolvedIndex, + addressType: resolvedAddressType, + correlationId: metamask?.correlationId, + synchronize, + accountName: accountNameSuggestion, + }); + + return mapToKeyringAccount(account); + } finally { + if (traceStarted) { + await this.#snapClient.endTrace(traceName); + } + } + } + + async createAccounts( + options: CreateAccountOptions, + ): Promise { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + ]); + + const { entropySource } = options; + + const range = + options.type === AccountCreationType.Bip44DeriveIndex + ? { from: options.groupIndex, to: options.groupIndex } + : options.range; + + if ( + !Number.isSafeInteger(range.from) || + !Number.isSafeInteger(range.to) || + range.from < 0 || + range.to < 0 + ) { + throw new FormatError( + 'Account index range is invalid: from and to must be non-negative integers', + ); + } + + if (range.from > range.to) { + throw new FormatError( + 'Account index range is invalid: from must be less than or equal to to', + ); + } + + // Only P2WPKH (BIP-84) on bitcoin mainnet is supported for v1, mirroring + // the defaults used by `createAccount` when no scope is provided. + const network = scopeToNetwork[BtcScope.Mainnet]; + const addressType = this.#defaultAddressType; + if (addressType !== 'p2wpkh') { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } + + const traceName = 'Create Bitcoin Accounts Batch'; + const traceStarted = await this.#snapClient.startTrace(traceName); + + try { + // `AccountUseCases.createMany` is idempotent: if an account already exists + // for the resolved derivation path, it will be returned as-is. + const accounts: BitcoinAccount[] = []; + let chunkFrom = range.from; + + while (chunkFrom <= range.to) { + const chunkTo = Math.min( + chunkFrom + MAX_CREATE_ACCOUNTS_PER_BATCH - 1, + range.to, + ); + const chunkRequests: CreateAccountParams[] = []; + + for (let index = chunkFrom; index <= chunkTo; index += 1) { + chunkRequests.push({ + network, + entropySource, + index, + addressType, + synchronize: false, + }); + } + + accounts.push( + ...(await this.#accountsUseCases.createMany(chunkRequests)), + ); + + if (chunkTo === range.to) { + break; + } + chunkFrom = chunkTo + 1; + } + + return accounts.map(mapToKeyringAccount); + } finally { + if (traceStarted) { + await this.#snapClient.endTrace(traceName); + } + } + } + + async discoverAccounts( + scopes: BtcScope[], + entropySource: string, + groupIndex: number, + ): Promise { + const accounts = await Promise.all( + scopes.flatMap((scope) => + // only discover P2WPKH addresses + [BtcAccountType.P2wpkh].map(async (addressType) => + this.#accountsUseCases.discover({ + network: scopeToNetwork[scope], + entropySource, + index: groupIndex, + addressType: caipToAddressType[addressType], + }), + ), + ), + ); + + // Return only accounts with history. + return accounts + .filter((account) => account.listTransactions().length > 0) + .map(mapToDiscoveredAccount); + } + + async getAccountBalances( + id: string, + ): Promise> { + const account = await this.#accountsUseCases.get(id); + const balance = Amount.from_sat(computeDisplayBalanceSats(account)) + .to_btc() + .toString(); + + return { + [networkToCaip19[account.network]]: { + amount: balance, + unit: networkToCurrencyUnit[account.network], + }, + }; + } + + async filterAccountChains(id: string, chains: string[]): Promise { + const account = await this.#accountsUseCases.get(id); + const accountChain = networkToScope[account.network]; + return chains.includes(accountChain) ? [accountChain] : []; + } + + async updateAccount(): Promise { + throw new InexistentMethodError('Method not supported.'); + } + + async deleteAccount(id: string): Promise { + await this.#accountsUseCases.delete(id); + } + + async listAccountAssets(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return [networkToCaip19[account.network]]; + } + + async listAccountTransactions( + id: string, + { limit, next }: Pagination, + ): Promise> { + const account = await this.#accountsUseCases.get(id); + const transactions = account.listTransactions(); + + // Find starting index based on provided cursor + let startIndex = 0; + if (next) { + const cursorIndex = transactions.findIndex( + (tx) => tx.txid.toString() === next, + ); + startIndex = cursorIndex >= 0 ? cursorIndex + 1 : 0; + } + + const paginatedTxs = transactions.slice(startIndex, startIndex + limit); + const hasMore = startIndex + limit < transactions.length; + const nextCursor = + hasMore && paginatedTxs.length > 0 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + paginatedTxs[paginatedTxs.length - 1]!.txid.toString() + : null; + + return { + data: paginatedTxs.map((tx) => mapToTransaction(account, tx)), + next: nextCursor, + }; + } + + async submitRequest(request: KeyringRequest): Promise { + return this.#keyringRequest.route(request); + } + + async setSelectedAccounts(accounts: string[]): Promise { + const accountIdSet = new Set(accounts); + const allAccounts = await this.#accountsUseCases.list(); + + validateSelectedAccounts( + accountIdSet, + allAccounts.map((acc) => acc.id), + ); + + // Schedule immediate background job to perform full scan + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: accounts }, + }); + } + + /** + * 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, BtcWalletRequestStruct); + + const allAccounts = await this.listAccounts(); + + const accountsWithThisScope = allAccounts.filter((account) => + account.scopes.includes(scope), + ); + + if (accountsWithThisScope.length === 0) { + throw new Error('No accounts with this scope'); + } + + const { address: addressToValidate } = + requestWithoutCommonHeader.params.account; + + const foundAccount = accountsWithThisScope.find( + (account) => account.address === addressToValidate, + ); + + if (!foundAccount) { + throw new Error('Account not found'); + } + + return { address: `${scope}:${addressToValidate}` }; + } catch (error: unknown) { + await this.#snapClient.emitTrackingError(error as Error); + + this.#logger.error({ error }, 'Error resolving account address'); + return null; + } + } + + #extractAddressType(path: string): AddressType { + const segments = path.split('/'); + if (segments.length < 4) { + throw new FormatError(`Invalid derivation path: ${path}`); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const purposePart = segments[1]!; + const match = purposePart.match(/^(\d+)/u); + if (!match) { + throw new FormatError(`Invalid purpose segment: ${purposePart}`); + } + + const purpose = Number(match[1]); + if (!Object.values(Purpose).includes(purpose)) { + throw new FormatError(`Invalid BIP-purpose: ${purpose}`); + } + + // only support native segwit (BIP-84) derivation paths for now + if ((purpose as Purpose) !== Purpose.NativeSegwit) { + throw new FormatError( + `Only native segwit (BIP-84) derivation paths are supported`, + ); + } + + const addressType = purposeToAddressType[purpose as Purpose]; + if (!addressType) { + throw new FormatError(`No address-type mapping for purpose: ${purpose}`); + } + + return addressType; + } + + #extractAccountIndex(path: string): number { + const segments = path.split('/'); + if (segments.length < 4) { + throw new FormatError(`Invalid derivation path: ${path}`); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accountPart = segments[3]!; + const match = accountPart.match(/^(\d+)/u); + if (!match) { + throw new FormatError(`Invalid account index: ${accountPart}`); + } + + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 0) { + throw new FormatError( + `Account index must be a non-negative integer, got: ${index}`, + ); + } + + return index; + } + + #getLowestUnusedIndex(accounts: BitcoinAccount[]): number { + if (accounts.length === 0) { + return 0; + } + + const usedIndices = accounts + .map((acc) => acc.accountIndex) + .sort((idxA, idxB) => idxA - idxB); + + 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/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts new file mode 100644 index 00000000..affd08e4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -0,0 +1,783 @@ +import type { Txid, Psbt, Amount, LocalOutput } from '@metamask/bitcoindevkit'; +import type { KeyringRequest } from '@metamask/keyring-api'; +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { AccountUseCases } from '../use-cases'; +import { KeyringRequestHandler } from './KeyringRequestHandler'; +import { + BroadcastPsbtRequest, + ComputeFeeRequest, + FillPsbtRequest, + GetUtxoRequest, + SendTransferRequest, + SignPsbtRequest, +} from './validation'; +import type { BitcoinAccount, ConfirmationRepository } from '../entities'; +import { AccountCapability } from '../entities'; +import type { Utxo } from './mappings'; +import { mapToUtxo } from './mappings'; +import { parsePsbt } from './parsers'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { from_string: jest.fn() }, + Amount: { from_btc: jest.fn() }, +})); + +jest.mock('superstruct', () => ({ + ...jest.requireActual('superstruct'), + assert: jest.fn(), +})); + +const mockPsbt = mock(); +jest.mock('./parsers', () => ({ + parsePsbt: jest.fn(), +})); +jest.mock('./mappings', () => ({ + mapToUtxo: jest.fn(), +})); + +describe('KeyringRequestHandler', () => { + const mockAccountsUseCases = mock(); + const mockConfirmationRepository = mock(); + const origin = 'metamask'; + + const ACCOUNT_ADDRESS = 'test-account-address'; + const accountParam = { + account: { address: ACCOUNT_ADDRESS }, + }; + + const handler = new KeyringRequestHandler( + mockAccountsUseCases, + mockConfirmationRepository, + ); + + beforeEach(() => { + jest.mocked(parsePsbt).mockReturnValue(mockPsbt); + }); + + describe('route', () => { + const mockRequest = mock({ + origin, + request: {}, + id: 'account-id', + scope: 'scope', + account: 'account-id', + }); + + it('throws error if unrecognized method', async () => { + await expect( + handler.route({ ...mockRequest, request: { method: 'randomMethod' } }), + ).rejects.toThrow('Unrecognized Bitcoin account capability'); + }); + }); + + describe('signPsbt', () => { + const mockOptions = { fill: false, broadcast: true }; + const mockAccount = mock(); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + options: mockOptions, + }, + }, + account: 'account-id', + }); + + beforeEach(() => { + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + mockConfirmationRepository.insertSignPsbt.mockResolvedValue(undefined); + }); + + it('executes signPsbt with confirmation and propagates canBeMalleable when broadcasting', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + canBeMalleable: false, + }); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + SignPsbtRequest, + ); + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountsUseCases.fillPsbt).not.toHaveBeenCalled(); + expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( + mockAccount, + mockPsbt, + 'metamask', + mockOptions, + ); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 'metamask', + { fill: false, broadcast: true }, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { + psbt: 'psbtBase64', + txid: 'txid', + canBeMalleable: false, + }, + }); + }); + + it('omits canBeMalleable when broadcast=false (no txid returned)', async () => { + const noBroadcastRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: 'psbtBase64', + feeRate: 3, + options: { fill: false, broadcast: false }, + }, + }, + account: 'account-id', + }); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + }); + + const result = await handler.route(noBroadcastRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'psbtBase64', txid: null }, + }); + }); + + it('throws AssertionError if usecase returns txid without canBeMalleable', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + // canBeMalleable intentionally omitted — protocol violation + }); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'signPsbt returned txid without canBeMalleable flag', + ); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { + psbt: 'psbtBase64', + txid: 'txid', + canBeMalleable: true, + }, + }); + }); + + it('fills the PSBT before showing the confirmation when options.fill is true', async () => { + const fillOptions = { fill: true, broadcast: true }; + const fillRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + options: fillOptions, + }, + }, + account: 'account-id', + }); + + const filledPsbt = mock({ + toString: jest.fn().mockReturnValue('filledPsbtBase64'), + }); + const psbtForConfirmation = mock(); + const psbtForSigning = mock(); + mockAccountsUseCases.fillPsbt.mockResolvedValue(filledPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'signedPsbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + canBeMalleable: false, + }); + jest + .mocked(parsePsbt) + .mockReturnValueOnce(mockPsbt) + .mockReturnValueOnce(psbtForConfirmation) + .mockReturnValueOnce(psbtForSigning); + + await handler.route(fillRequest); + + const fillOrder = + mockAccountsUseCases.fillPsbt.mock.invocationCallOrder[0]; + const insertOrder = + mockConfirmationRepository.insertSignPsbt.mock.invocationCallOrder[0]; + const signOrder = + mockAccountsUseCases.signPsbt.mock.invocationCallOrder[0]; + + expect(fillOrder).toBeLessThan(insertOrder as number); + expect(insertOrder).toBeLessThan(signOrder as number); + + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(parsePsbt).toHaveBeenNthCalledWith(1, 'psbtBase64'); + expect(parsePsbt).toHaveBeenNthCalledWith(2, 'filledPsbtBase64'); + expect(parsePsbt).toHaveBeenNthCalledWith(3, 'filledPsbtBase64'); + expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( + mockAccount, + psbtForConfirmation, + 'metamask', + fillOptions, + ); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + 'account-id', + psbtForSigning, + 'metamask', + { fill: false, broadcast: true }, + 3, + ); + expect(psbtForSigning).not.toBe(psbtForConfirmation); + }); + + it('returns null txid when signPsbt result has no txid', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: undefined, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'psbtBase64', txid: null }, + }); + }); + + it('does not sign if user cancels confirmation', async () => { + mockConfirmationRepository.insertSignPsbt.mockRejectedValue( + new Error('User canceled the confirmation'), + ); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'User canceled the confirmation', + ); + + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + + it('does not show confirmation or sign if fillPsbt fails', async () => { + const fillOptions = { fill: true, broadcast: true }; + const fillRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + options: fillOptions, + }, + }, + account: 'account-id', + }); + const error = new Error('fee rate too high'); + mockAccountsUseCases.fillPsbt.mockRejectedValue(error); + + await expect(handler.route(fillRequest)).rejects.toThrow(error); + + expect(mockConfirmationRepository.insertSignPsbt).not.toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { + ...mockRequest.request, + params: { + ...accountParam, + psbt: 'invalidPsbt', + options: mockOptions, + }, + }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from signPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); + }); + }); + + describe('computeFee', () => { + const mockRequest = mock({ + request: { + method: AccountCapability.ComputeFee, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes computeFee', async () => { + mockAccountsUseCases.computeFee.mockResolvedValue( + mock({ + to_sat: () => BigInt(1000), + }), + ); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + ComputeFeeRequest, + ); + expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { fee: '1000' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled(); + }); + + it('propagates errors from computeFee', async () => { + const error = new Error(); + mockAccountsUseCases.computeFee.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).toHaveBeenCalled(); + }); + }); + + describe('fillPsbt', () => { + const mockRequest = mock({ + request: { + method: AccountCapability.FillPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes fillPsbt', async () => { + const mockFilledPsbt = mock({ + toString: jest.fn().mockReturnValue('filledPsbtBase64'), + }); + mockAccountsUseCases.fillPsbt.mockResolvedValue(mockFilledPsbt); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + FillPsbtRequest, + ); + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'filledPsbtBase64' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.fillPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from fillPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.fillPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalled(); + }); + }); + + describe('broadcastPsbt', () => { + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes broadcastPsbt and propagates canBeMalleable', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.broadcastPsbt.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: false, + }); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + BroadcastPsbtRequest, + ); + expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + origin, + ); + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: false }, + }); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.broadcastPsbt.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: true }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.broadcastPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from fillPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.broadcastPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalled(); + }); + }); + + describe('sendTransfer', () => { + const recipients = [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ]; + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SendTransfer, + params: { + ...accountParam, + recipients, + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes sendTransfer and propagates canBeMalleable', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.sendTransfer.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: false, + }); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + SendTransferRequest, + ); + expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalledWith( + 'account-id', + recipients, + origin, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: false }, + }); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.sendTransfer.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: true }, + }); + }); + + it('propagates errors from sendTransfer', async () => { + const error = new Error(); + mockAccountsUseCases.sendTransfer.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalled(); + }); + }); + + describe('getUtxo', () => { + const mockLocalOutput = mock(); + const mockAccount = mock({ + getUtxo: () => mockLocalOutput, + network: 'bitcoin', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.GetUtxo, + params: { + ...accountParam, + outpoint: 'mytxid:0', + }, + }, + account: 'account-id', + }); + + it('executes getUtxo', async () => { + const expectedUtxo = { + derivationIndex: 0, + outpoint: 'mytxid:0', + value: '1000', + scriptPubkey: 'scriptPubkey', + scriptPubkeyHex: 'scriptPubkeyHex', + }; + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + jest.mocked(mapToUtxo).mockReturnValue(expectedUtxo); + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + GetUtxoRequest, + ); + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: expectedUtxo, + }); + }); + + it('throws NotFoundError when UTXO does not exist', async () => { + const emptyAccount = mock({ + getUtxo: () => undefined, + network: 'bitcoin', + }); + mockAccountsUseCases.get.mockResolvedValue(emptyAccount); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'UTXO not found', + ); + }); + }); + + describe('listUtxos', () => { + const mockLocalOutput = mock(); + const mockAccount = mock({ + listUnspent: () => [mockLocalOutput, mockLocalOutput], + network: 'bitcoin', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.ListUtxos, + }, + account: 'account-id', + }); + + it('executes listUtxos', async () => { + const mockUtxo = mock({ + derivationIndex: 0, + outpoint: 'mytxid:0', + value: '1000', + scriptPubkey: 'scriptPubkey', + scriptPubkeyHex: 'scriptPubkeyHex', + }); + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + jest.mocked(mapToUtxo).mockReturnValue(mockUtxo); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: [mockUtxo, mockUtxo], + }); + }); + }); + + describe('publicDescriptor', () => { + const mockAccount = mock({ + publicDescriptor: 'publicDescriptor', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.PublicDescriptor, + }, + account: 'account-id', + }); + + it('executes publicDescriptor', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: 'publicDescriptor', + }); + }); + }); + + describe('signMessage', () => { + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SignMessage, + params: { + ...accountParam, + message: 'message', + }, + }, + account: 'account-id', + }); + + it('executes signMessage', async () => { + mockAccountsUseCases.signMessage.mockResolvedValue('signature'); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.signMessage).toHaveBeenCalledWith( + 'account-id', + 'message', + 'metamask', + ); + expect(result).toStrictEqual({ + pending: false, + result: { signature: 'signature' }, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts new file mode 100644 index 00000000..170ec887 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -0,0 +1,285 @@ +import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; +import type { Json } from '@metamask/snaps-sdk'; +import { assert } from 'superstruct'; + +import type { ConfirmationRepository } from '../entities'; +import { + AccountCapability, + AssertionError, + InexistentMethodError, + NotFoundError, +} from '../entities'; +import { mapToUtxo } from './mappings'; +import { parsePsbt } from './parsers'; +import { + BroadcastPsbtRequest, + ComputeFeeRequest, + FillPsbtRequest, + GetUtxoRequest, + SendTransferRequest, + SignMessageRequest, + SignPsbtRequest, +} from './validation'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; + +export type SignPsbtResponse = { + psbt: string; + txid: string | null; + // Present only when broadcast happened. True if the source account's + // address type allows third-party txid malleation before confirmation + // (currently only legacy P2PKH). + canBeMalleable?: boolean; +}; + +export type ComputeFeeResponse = { + // Fee in satoshis + fee: string; +}; + +export type BroadcastPsbtResponse = { + txid: string; + // True if the source account's address type allows third-party txid + // malleation before confirmation (currently only legacy P2PKH). + canBeMalleable: boolean; +}; + +export type FillPsbtResponse = { + psbt: string; +}; + +export type SignMessageResponse = { + signature: string; +}; + +export class KeyringRequestHandler { + readonly #accountsUseCases: AccountUseCases; + + readonly #confirmationRepository: ConfirmationRepository; + + constructor( + accounts: AccountUseCases, + confirmationRepository: ConfirmationRepository, + ) { + this.#accountsUseCases = accounts; + this.#confirmationRepository = confirmationRepository; + } + + async route(request: KeyringRequest): Promise { + const { account, request: requestData, origin } = request; + const { method, params } = requestData; + + switch (method as AccountCapability) { + case AccountCapability.SignPsbt: { + assert(params, SignPsbtRequest); + const { psbt, feeRate, options } = params; + return this.#signPsbt(account, psbt, origin, options, feeRate); + } + case AccountCapability.FillPsbt: { + assert(params, FillPsbtRequest); + return this.#fillPsbt(account, params.psbt, params.feeRate); + } + case AccountCapability.ComputeFee: { + assert(params, ComputeFeeRequest); + return this.#computeFee(account, params.psbt, params.feeRate); + } + case AccountCapability.BroadcastPsbt: { + assert(params, BroadcastPsbtRequest); + return this.#broadcastPsbt(account, params.psbt, origin); + } + case AccountCapability.SendTransfer: { + assert(params, SendTransferRequest); + return this.#sendTransfer( + account, + params.recipients, + origin, + params.feeRate, + ); + } + case AccountCapability.GetUtxo: { + assert(params, GetUtxoRequest); + return this.#getUtxo(account, params.outpoint); + } + case AccountCapability.ListUtxos: { + return this.#listUtxos(account); + } + case AccountCapability.PublicDescriptor: { + return this.#publicDescriptor(account); + } + case AccountCapability.SignMessage: { + assert(params, SignMessageRequest); + return this.#signMessage(account, params.message, origin); + } + default: { + throw new InexistentMethodError( + 'Unrecognized Bitcoin account capability', + { + account, + method, + }, + ); + } + } + } + + async #signPsbt( + id: string, + psbtBase64: string, + origin: string, + options: { fill: boolean; broadcast: boolean }, + feeRate?: number, + ): Promise { + const account = await this.#accountsUseCases.get(id); + + const psbtBase64ToSign = options.fill + ? ( + await this.#accountsUseCases.fillPsbt( + id, + parsePsbt(psbtBase64), + feeRate, + ) + ).toString() + : psbtBase64; + + await this.#confirmationRepository.insertSignPsbt( + account, + parsePsbt(psbtBase64ToSign), + origin, + options, + ); + + const { + psbt: signedPsbt, + txid, + canBeMalleable, + } = await this.#accountsUseCases.signPsbt( + id, + parsePsbt(psbtBase64ToSign), + origin, + { ...options, fill: false }, + feeRate, + ); + // Invariant: signPsbt sets txid and canBeMalleable together (when broadcast + // happened) or neither (when it didn't). A txid without the flag would + // leak a possibly-malleable txid to the consumer. + if (txid !== undefined && canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); + } + const response: SignPsbtResponse = { + psbt: signedPsbt.toString(), + txid: txid?.toString() ?? null, + }; + if (canBeMalleable !== undefined) { + response.canBeMalleable = canBeMalleable; + } + return this.#toKeyringResponse(response); + } + + async #fillPsbt( + id: string, + psbtBase64: string, + feeRate?: number, + ): Promise { + const psbt = await this.#accountsUseCases.fillPsbt( + id, + parsePsbt(psbtBase64), + feeRate, + ); + return this.#toKeyringResponse({ + psbt: psbt.toString(), + } as FillPsbtResponse); + } + + async #computeFee( + id: string, + psbtBase64: string, + feeRate?: number, + ): Promise { + const fee = await this.#accountsUseCases.computeFee( + id, + parsePsbt(psbtBase64), + feeRate, + ); + return this.#toKeyringResponse({ + fee: fee.to_sat().toString(), + } as ComputeFeeResponse); + } + + async #broadcastPsbt( + id: string, + psbtBase64: string, + origin: string, + ): Promise { + const { txid, canBeMalleable } = await this.#accountsUseCases.broadcastPsbt( + id, + parsePsbt(psbtBase64), + origin, + ); + return this.#toKeyringResponse({ + txid: txid.toString(), + canBeMalleable, + } as BroadcastPsbtResponse); + } + + async #sendTransfer( + id: string, + recipients: { address: string; amount: string }[], + origin: string, + feeRate?: number, + ): Promise { + const { txid, canBeMalleable } = await this.#accountsUseCases.sendTransfer( + id, + recipients, + origin, + feeRate, + ); + return this.#toKeyringResponse({ + txid: txid.toString(), + canBeMalleable, + } as BroadcastPsbtResponse); + } + + async #getUtxo(id: string, outpoint: string): Promise { + const account = await this.#accountsUseCases.get(id); + const utxo = account.getUtxo(outpoint); + if (!utxo) { + throw new NotFoundError('UTXO not found', { id }); + } + return this.#toKeyringResponse(mapToUtxo(utxo, account.network)); + } + + async #listUtxos(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return this.#toKeyringResponse( + account.listUnspent().map((utxo) => mapToUtxo(utxo, account.network)), + ); + } + + async #publicDescriptor(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return this.#toKeyringResponse(account.publicDescriptor); + } + + async #signMessage( + id: string, + message: string, + origin: string, + ): Promise { + const signature = await this.#accountsUseCases.signMessage( + id, + message, + origin, + ); + return this.#toKeyringResponse({ + signature, + } as SignMessageResponse); + } + + #toKeyringResponse(result: Json): KeyringResponse { + return { + pending: false, + result, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts new file mode 100644 index 00000000..597d5faf --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -0,0 +1,1256 @@ +import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; +import type { Transaction, Txid } from '@metamask/bitcoindevkit'; +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { BtcScope, FeeType } from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; +import { Caip19Asset } from './caip'; +import { RpcHandler } from './RpcHandler'; +import { RpcMethod, SendErrorCodes } from './validation'; +import type { Logger, BitcoinAccount } from '../entities'; +import { mapPsbtToTransaction } from './mappings'; + +const mockPsbt = mock(); +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Psbt: { from_string: jest.fn() }, + Address: { + from_string: jest.fn(), + }, + Amount: { + from_btc: jest.fn(), + }, +})); + +jest.mock('./mappings', () => ({ + ...jest.requireActual('./mappings'), + mapPsbtToTransaction: jest.fn(), +})); + +describe('RpcHandler', () => { + const mockSendFlowUseCases = mock(); + const mockAccountsUseCases = mock(); + const mockLogger = mock(); + const origin = 'metamask'; + const validAccountId = '724ac464-6572-4d9c-a8e2-4075c8846d65'; + + const handler = new RpcHandler( + mockSendFlowUseCases, + mockAccountsUseCases, + mockLogger, + ); + + beforeEach(() => { + jest.clearAllMocks(); + + jest.mocked(Psbt.from_string).mockReturnValue(mockPsbt); + + // setup Address mock with validation logic + const validAddresses = [ + 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', // bech32 mainnet + 'bc1qtest123address', // test address + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // P2PKH mainnet + '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', // P2SH mainnet + ]; + + jest + .mocked(Address.from_string) + .mockImplementation((address: string, _: string) => { + if (validAddresses.includes(address)) { + return { toString: () => address } as any; + } + throw new Error(`Invalid address: ${address}`); + }); + }); + + describe('parameter validation', () => { + describe('onAddressInput validation', () => { + beforeEach(() => { + const mockAccount = mock({ network: 'bitcoin' }); + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + }); + + it('rejects invalid address format', async () => { + const invalidAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAddressInput, + params: { + value: 'not-a-valid-address', + accountId: validAccountId, + }, + }; + + const result = await handler.route(origin, invalidAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid address for network %s. Error: %s', + 'bitcoin', + 'Invalid address: not-a-valid-address', + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects invalid UUID accountId', async () => { + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAddressInput, + params: { + value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + accountId: 'not-a-uuid', + }, + }; + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('rejects missing value parameter', async () => { + const missingValueRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAddressInput, + params: { + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + // Missing 'value' field + } as any, + }; + + await expect( + handler.route(origin, missingValueRequest), + ).rejects.toThrow('At path: value -- Expected a string'); + }); + + it('rejects missing accountId parameter', async () => { + const missingAccountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAddressInput, + params: { + value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + // Missing 'accountId' field + } as any, + }; + + await expect( + handler.route(origin, missingAccountRequest), + ).rejects.toThrow('At path: accountId -- Expected a string'); + }); + }); + + describe('onAmountInput validation', () => { + it('rejects invalid UUID accountId', async () => { + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'not-a-uuid', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('rejects negative amounts', async () => { + const negativeAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '-0.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, negativeAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects zero amount', async () => { + const zeroAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, zeroAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects invalid number formats', async () => { + const testCases = ['abc', '1.2.3', 'not-a-number', 'NaN', 'Infinity']; + + for (const invalidValue of testCases) { + const invalidAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: invalidValue, + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, invalidAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + } + }); + + it('rejects missing assetId parameter', async () => { + const missingAssetRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + // Missing 'assetId' field + } as any, + }; + + await expect( + handler.route(origin, missingAssetRequest), + ).rejects.toThrow( + 'At path: assetId -- Expected a value of type `CaipAssetType`', + ); + }); + + it('rejects invalid assetId format', async () => { + const invalidAssetRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'invalid-asset-id', + }, + }; + + await expect( + handler.route(origin, invalidAssetRequest), + ).rejects.toThrow( + 'At path: assetId -- Expected a value of type `CaipAssetType`', + ); + }); + }); + + describe('verifyMessage validation', () => { + it('rejects missing parameters', async () => { + const missingParamsRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.VerifyMessage, + params: { + address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + // Missing 'message' and 'signature' + } as any, + }; + + await expect( + handler.route(origin, missingParamsRequest), + ).rejects.toThrow('At path: message -- Expected a string'); + }); + }); + }); + + describe('route', () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.StartSendTransactionFlow, + params: { + account: validAccountId, + }, + }; + + it('throws error if missing params', async () => { + await expect( + handler.route(origin, { ...request, params: undefined }), + ).rejects.toThrow('Missing params'); + }); + + it('throws error if unrecognized method', async () => { + await expect( + handler.route(origin, { ...request, method: 'randomMethod' }), + ).rejects.toThrow('Method not found: randomMethod'); + }); + }); + + describe('executeSendFlow', () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.StartSendTransactionFlow, + params: { + account: validAccountId, + }, + }; + + it('executes startSendTransactionFlow', async () => { + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + canBeMalleable: false, + }); + + const result = await handler.route(origin, request); + + expect(mockSendFlowUseCases.display).toHaveBeenCalledWith(validAccountId); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + validAccountId, + mockPsbt, + 'metamask', + { broadcast: true, fill: false }, + ); + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: false, + }); + }); + + it('propagates canBeMalleable=true from legacy P2PKH accounts', async () => { + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + canBeMalleable: true, + }); + + const result = await handler.route(origin, request); + + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: true, + }); + }); + + it('throws when canBeMalleable is missing (signPsbt did not broadcast)', async () => { + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + // canBeMalleable intentionally omitted + }); + + await expect(handler.route(origin, request)).rejects.toThrow( + 'signPsbt returned txid without canBeMalleable flag', + ); + }); + + it('propagates errors from display', async () => { + const error = new Error(); + mockSendFlowUseCases.display.mockRejectedValue(error); + + await expect(handler.route(origin, request)).rejects.toThrow(error); + + expect(mockSendFlowUseCases.display).toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from send', async () => { + const error = new Error(); + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); + + await expect(handler.route(origin, request)).rejects.toThrow(error); + + expect(mockSendFlowUseCases.display).toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); + }); + }); + + describe('signAndSendTransaction', () => { + const psbt = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA='; + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.SignAndSendTransaction, + params: { + accountId: validAccountId, + transaction: psbt, + }, + }; + + it('executes signAndSendTransaction', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + canBeMalleable: false, + }); + + const result = await handler.route(origin, request); + + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + validAccountId, + mockPsbt, + 'metamask', + { broadcast: true, fill: true }, + ); + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: false, + }); + }); + + it('propagates errors from signAndSendTransaction', async () => { + const error = new Error(); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); + + await expect(handler.route(origin, request)).rejects.toThrow(error); + + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); + }); + }); + + describe('computeFee', () => { + const psbt = 'someEncodedPsbt'; + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ComputeFee, + params: { + accountId: validAccountId, + transaction: psbt, + scope: BtcScope.Mainnet, + }, + }; + + it('executes computeFee', async () => { + const mockAmount = mock({ + to_btc: jest.fn().mockReturnValue('0.00001'), + }); + mockAccountsUseCases.computeFee.mockResolvedValue(mockAmount); + + const result = await handler.route(origin, request); + + expect(Psbt.from_string).toHaveBeenCalledWith(psbt); + expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( + validAccountId, + mockPsbt, + ); + expect(result).toStrictEqual([ + { + type: FeeType.Priority, + asset: { + unit: 'BTC', + type: Caip19Asset.Bitcoin, + amount: '0.00001', + fungible: true, + }, + }, + ]); + }); + + it('propagates errors from computeFee', async () => { + const error = new Error('Insufficient funds'); + mockAccountsUseCases.computeFee.mockRejectedValue(error); + + await expect(handler.route(origin, request)).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).toHaveBeenCalled(); + }); + + it('throws FormatError for invalid PSBT', async () => { + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ComputeFee, + params: { + accountId: validAccountId, + transaction: 'invalid-psbt-base64', + scope: BtcScope.Mainnet, + }, + }; + + jest.mocked(Psbt.from_string).mockImplementationOnce(() => { + throw new Error('Invalid PSBT'); + }); + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Invalid PSBT', + ); + + expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled(); + }); + }); + + describe('onAddressInput', () => { + const mockBitcoinAccount = { + network: 'bitcoin', + }; + + const validAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAddressInput, + params: { + value: 'bc1qtest123address', + accountId: validAccountId, + }, + }; + + beforeEach(() => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount as any); + }); + + it('validates a correct address', async () => { + const result = await handler.route(origin, validAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('handles account not found error', async () => { + const accountError = new Error('Account not found'); + mockAccountsUseCases.get.mockRejectedValue(accountError); + + const result = await handler.route(origin, validAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid account. Error: %s', + 'Account not found', + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + }); + + describe('onAmountInput', () => { + const validAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0.5', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + beforeEach(() => { + const mockTrustedSpendable = mock(); + mockTrustedSpendable.to_sat.mockReturnValue(BigInt(150_000_000)); + mockTrustedSpendable.to_btc.mockReturnValue(1.5); + + const mockAmountAccount = { + network: 'bitcoin', + addressType: 'p2wpkh', + balance: { + trusted_spendable: mockTrustedSpendable, + }, + }; + mockAccountsUseCases.get.mockResolvedValue(mockAmountAccount as any); + + (Amount.from_btc as jest.Mock).mockImplementation((btc) => ({ + to_sat: () => BigInt(Math.round(btc * 100_000_000)), + })); + }); + + it('validates a correct amount within balance', async () => { + const result = await handler.route(origin, validAmountRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('rejects amount exceeding balance', async () => { + const excessiveAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '2.0', // more than account's balance + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, excessiveAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + + it('handles account not found error', async () => { + const accountError = new Error('Account not found'); + mockAccountsUseCases.get.mockRejectedValue(accountError); + + const result = await handler.route(origin, validAmountRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Account not found', + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('accepts amount equal to segwit dust limit for p2wpkh', async () => { + const segwitDustOkRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0.00000294', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, segwitDustOkRequest); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + }); + + it('rejects amount below segwit dust limit for p2wpkh', async () => { + const segwitDustTooLowRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0.00000293', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, segwitDustTooLowRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + }); + + describe('verifyMessage', () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.VerifyMessage, + params: { + address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + message: 'Hello, world!', + signature: + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', + }, + }; + + it('executes verifyMessage successfully with valid signature', async () => { + const result = await handler.route(origin, request); + + expect(result).toStrictEqual({ valid: true }); + }); + + it('executes verifyMessage successfully with invalid signature', async () => { + const result = await handler.route(origin, { + ...request, + params: { + ...request.params, + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', // wrong address for given signature + }, + } as JsonRpcRequest); + + expect(result).toStrictEqual({ valid: false }); + }); + + it('throws ValidationError for invalid signature', async () => { + await expect( + handler.route(origin, { + ...request, + params: { ...request.params, signature: 'invalidaSignature' }, + } as JsonRpcRequest), + ).rejects.toThrow('Failed to verify signature'); + }); + }); + + describe('confirmSend', () => { + const mockAccount = mock(); + const mockTransaction = mock(); + + const validRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + beforeEach(() => { + mockAccount.id = validAccountId; + mockAccount.network = 'bitcoin'; + + const mockBalanceAmount = mock(); + mockBalanceAmount.to_sat.mockReturnValue(BigInt(100_000_000)); // 1 BTC in satoshis + mockBalanceAmount.to_btc.mockReturnValue(1); + mockAccount.balance = { + trusted_spendable: mockBalanceAmount, + } as any; + + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + mockSendFlowUseCases.confirmSendFlow.mockResolvedValue(mockTransaction); + + // mock Amount.from_btc to return an object with to_sat method + (Amount.from_btc as jest.Mock).mockImplementation((btc) => ({ + to_sat: () => BigInt(Math.round(btc * 100_000_000)), + })); + + // we mock the mapping function since we don't care about the result structure here + // it is tested in mappings.test.ts + jest.mocked(mapPsbtToTransaction).mockReturnValue({ + canBeMalleable: false, + } as KeyringTransaction & { + canBeMalleable: boolean; + }); + }); + + it('creates and signs a transaction successfully', async () => { + const result = await handler.route(origin, validRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith( + mockAccount, + '0.0001', + 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + ); + expect(mapPsbtToTransaction).toHaveBeenCalledWith( + mockAccount, + mockTransaction, + ); + + expect(result).toBeDefined(); + }); + + it('passes the canBeMalleable flag through from mapPsbtToTransaction', async () => { + jest.mocked(mapPsbtToTransaction).mockReturnValueOnce({ + id: 'tx-id', + canBeMalleable: true, + } as unknown as KeyringTransaction & { canBeMalleable: boolean }); + + const result = await handler.route(origin, validRequest); + + expect((result as any).canBeMalleable).toBe(true); + }); + + it('handles different amounts and addresses', async () => { + const customRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + amount: '0.0005', + assetId: Caip19Asset.Bitcoin, + }, + }; + + await handler.route(origin, customRequest); + + expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith( + mockAccount, + '0.0005', + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + ); + }); + + it('throws error when account is not found', async () => { + mockAccountsUseCases.get.mockRejectedValue( + new Error('Account not found'), + ); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + 'Account not found', + ); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Account not found', + ); + }); + + it('throws error when confirmSendFlow fails', async () => { + const sendError = new Error('Failed to build transaction'); + mockSendFlowUseCases.confirmSendFlow.mockRejectedValue(sendError); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + sendError.message, + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + sendError.message, + ); + }); + + it('validates request parameters', async () => { + const missingFieldRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + // missing fromAccountId + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + } as any, + }; + + await expect(handler.route(origin, missingFieldRequest)).rejects.toThrow( + 'At path:', + ); + + // invalid UUID format + const invalidUuidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: 'not-a-uuid', + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + await expect(handler.route(origin, invalidUuidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('returns validation error for invalid amount', async () => { + const invalidAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '-0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, invalidAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('returns validation error for invalid address', async () => { + const invalidAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'invalid-address', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, invalidAddressRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('returns error when PSBT construction fails due to insufficient funds for fees', async () => { + // small balance that won't cover amount + fees + const smallBalanceAmount = mock(); + smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis + smallBalanceAmount.to_btc.mockReturnValue(0.00005); + + const mockBalance = { + trusted_spendable: smallBalanceAmount, + free: mock(), + immature: mock(), + trusted_pending: mock(), + untrusted_pending: mock(), + coin_count: 1, + coin_value: mock(), + }; + + const smallBalanceAccount = mock(); + smallBalanceAccount.id = validAccountId; + smallBalanceAccount.network = 'bitcoin'; + smallBalanceAccount.balance = mockBalance as any; + + mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); + + // mock confirmSendFlow to throw an insufficient funds error + mockSendFlowUseCases.confirmSendFlow.mockRejectedValue( + new Error( + 'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed', + ), + ); + + const insufficientBalanceRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.00005', // 0.00005 BTC (5000 sats) + 0.00001 BTC fee (1000 sats) > 0.00005 BTC balance (5000 sats) + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, insufficientBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }); + + it('returns validation error for insufficient balance', async () => { + const smallBalanceAmount = mock(); + smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis + smallBalanceAmount.to_btc.mockReturnValue(0.00005); + + const mockBalance = { + trusted_spendable: smallBalanceAmount, + free: mock(), + immature: mock(), + trusted_pending: mock(), + untrusted_pending: mock(), + coin_count: 1, + coin_value: mock(), + }; + + const smallBalanceAccount = mock(); + smallBalanceAccount.id = validAccountId; + smallBalanceAccount.network = 'bitcoin'; + smallBalanceAccount.balance = mockBalance as any; + + mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); + + const mockSignedPsbtWithFee = mock(); + const mockFeeAmount = mock(); + mockFeeAmount.to_sat.mockReturnValue(BigInt(1000)); // 0.00001 BTC fee in satoshis + mockSignedPsbtWithFee.fee.mockReturnValue(mockFeeAmount); + jest.mocked(Psbt.from_string).mockReturnValue(mockSignedPsbtWithFee); + + const insufficientBalanceRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.00006', // 0.00006 BTC (6000 sats) + 0.00001 BTC fee (1000 sats) > 0.00005 BTC balance (5000 sats) + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, insufficientBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + }); + + describe('signRewardsMessage', () => { + const mockBitcoinAccount = mock({ + id: validAccountId, + publicAddress: { + toString: () => 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k', + }, + network: 'bitcoin', + }); + + it('successfully signs a valid rewards message', async () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const timestamp = 1736660000; + const message = btoa(`rewards,${address},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + + const signMessageSpy = jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-signature-base64' as never); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: validAccountId, + message, + }, + }; + + const result = await handler.route(origin, request); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(Address.from_string).toHaveBeenCalledWith(address, 'bitcoin'); + expect(signMessageSpy).toHaveBeenCalledWith( + validAccountId, + `rewards,${address},${timestamp}`, + 'metamask', + { skipConfirmation: true }, + ); + expect(result).toStrictEqual({ signature: 'mock-signature-base64' }); + }); + + it('throws error when address in message does not match account', async () => { + const differentAddress = 'bc1qdifferentaddress123456789abcdefgh'; + const timestamp = 1736660000; + const message = btoa(`rewards,${differentAddress},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: validAccountId, + message, + }, + }; + + await expect(handler.route(origin, request)).rejects.toThrow( + 'does not match signing account address', + ); + }); + + it('throws error when account is not found', async () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const timestamp = 1736660000; + const message = btoa(`rewards,${address},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue( + null as unknown as BitcoinAccount, + ); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: 'non-existent-account', + message, + }, + }; + + await expect(handler.route(origin, request)).rejects.toThrow( + 'Account not found', + ); + }); + }); + + describe('signProofOfOwnership', () => { + const accountAddress = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + const mockBitcoinAccount = mock({ + id: validAccountId, + publicAddress: { + toString: () => accountAddress, + }, + network: 'bitcoin', + }); + + const buildRequest = ( + message: string, + accountId: string = validAccountId, + ): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignProofOfOwnership, + params: { accountId, message }, + }); + + beforeEach(() => { + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + }); + + it('signs the proof message and returns the BIP-322 signature', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + const signMessageSpy = jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-bip322-signature' as never); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + const result = await handler.route(origin, buildRequest(message)); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(Address.from_string).toHaveBeenCalledWith( + accountAddress, + 'bitcoin', + ); + expect(signMessageSpy).toHaveBeenCalledWith( + validAccountId, + message, + 'metamask', + { skipConfirmation: true }, + ); + expect(result).toStrictEqual({ signature: 'mock-bip322-signature' }); + }); + + it('canonicalizes mixed-case bech32 addresses before validating and comparing', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-bip322-signature' as never); + + const uppercased = accountAddress.toUpperCase(); + const message = `metamask:proof-of-ownership:${nonce}:${uppercased}`; + + const result = await handler.route(origin, buildRequest(message)); + + // BDK's `Address.from_string` rejects mixed-case bech32 per BIP-173. + // Asserting it was called with the lowercase form (and never the + // uppercase input) is what guards against the canonicalization step + // being moved back after `validateAddress`. + expect(Address.from_string).toHaveBeenCalledWith( + accountAddress, + 'bitcoin', + ); + expect(Address.from_string).not.toHaveBeenCalledWith( + uppercased, + 'bitcoin', + ); + expect(result).toStrictEqual({ signature: 'mock-bip322-signature' }); + }); + + it('throws when the message does not start with the proof prefix', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + + await expect( + handler.route( + origin, + buildRequest(`rewards,${accountAddress},1736660000`), + ), + ).rejects.toThrow('Message must start with'); + }); + + it('throws when the account is not found', async () => { + mockAccountsUseCases.get.mockResolvedValue( + null as unknown as BitcoinAccount, + ); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message, 'missing')), + ).rejects.toThrow('Account not found'); + }); + + it('throws when the embedded address is invalid for the account network', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest.mocked(Address.from_string).mockImplementationOnce(() => { + throw new Error('invalid address'); + }); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow( + 'Invalid Bitcoin address in proof-of-ownership message', + ); + }); + + it('throws when the embedded address does not match the signing account', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + const otherAddress = 'bc1qdifferentaddress123456789abcdefgh'; + const message = `metamask:proof-of-ownership:${nonce}:${otherAddress}`; + + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow('does not match signing account address'); + }); + + it('propagates errors from the signer', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockRejectedValue(new Error('signer unavailable') as never); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow('signer unavailable'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts new file mode 100644 index 00000000..1722459e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -0,0 +1,458 @@ +import { BtcScope } from '@metamask/keyring-api'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { Verifier } from 'bip322-js'; +import { + assert, + enums, + object, + optional, + record, + string, + unknown, +} from 'superstruct'; + +import { + AssertionError, + type CodifiedError, + FormatError, + InexistentMethodError, + type Logger, + ValidationError, +} from '../entities'; +import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; +import { scopeToNetwork } from './caip'; +import type { TransactionFee } from './mappings'; +import { mapPsbtToTransaction, mapToTransactionFees } from './mappings'; +import { parsePsbt } from './parsers'; +import type { + ConfirmSendRequest, + OnAddressInputRequest, + OnAmountInputRequest, +} from './types'; +import type { ValidationResponse } from './validation'; +import { + NO_ERRORS_RESPONSE, + INVALID_RESPONSE, + ConfirmSendRequestStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + RpcMethod, + SendErrorCodes, + validateAmount, + validateAddress, + validateAccountBalance, + validateDustLimit, + parseRewardsMessage, + parseProofOfOwnershipMessage, + canonicalizeBitcoinAddress, +} from './validation'; + +export const CreateSendFormRequest = object({ + account: string(), + scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation + assetId: optional(string()), // We don't use the Caip19 but need to define it for validation +}); + +export const SendPsbtRequest = object({ + accountId: string(), + transaction: string(), + scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation + options: optional(record(string(), unknown())), // We don't use the options but need to define it for validation +}); + +export const ComputeFeeRequest = object({ + accountId: string(), + transaction: string(), + scope: enums(Object.values(BtcScope)), +}); + +export type SendTransactionResponse = { + transactionId: string; + // True if the source account's address type allows third-party txid + // malleation before confirmation (currently only legacy P2PKH). + canBeMalleable: boolean; +}; + +export const VerifyMessageRequest = object({ + address: string(), + message: string(), + signature: string(), +}); + +export const SignRewardsMessageRequest = object({ + accountId: string(), + message: string(), +}); + +export const SignProofOfOwnershipRequest = object({ + accountId: string(), + message: string(), +}); + +export class RpcHandler { + readonly #logger: Logger; + + readonly #sendFlowUseCases: SendFlowUseCases; + + readonly #accountUseCases: AccountUseCases; + + constructor( + sendFlow: SendFlowUseCases, + accounts: AccountUseCases, + logger: Logger, + ) { + this.#logger = logger; + this.#sendFlowUseCases = sendFlow; + this.#accountUseCases = accounts; + } + + async route(origin: string, request: JsonRpcRequest): Promise { + const { method, params } = request; + if (!params) { + throw new FormatError('Missing params'); + } + + switch (method as RpcMethod) { + case RpcMethod.StartSendTransactionFlow: { + assert(params, CreateSendFormRequest); + return this.#executeSendFlow(params.account, origin); + } + case RpcMethod.SignAndSendTransaction: { + assert(params, SendPsbtRequest); + return this.#signAndSend(params.accountId, params.transaction, origin); + } + case RpcMethod.ComputeFee: { + assert(params, ComputeFeeRequest); + return this.#computeFee( + params.accountId, + params.transaction, + params.scope, + ); + } + case RpcMethod.OnAddressInput: { + assert(params, OnAddressInputRequestStruct); + return this.#onAddressInput(params); + } + case RpcMethod.OnAmountInput: { + assert(params, OnAmountInputRequestStruct); + return this.#onAmountInput(params); + } + case RpcMethod.ConfirmSend: { + assert(params, ConfirmSendRequestStruct); + return await this.#confirmSend(params); + } + case RpcMethod.VerifyMessage: { + assert(params, VerifyMessageRequest); + return this.#verifyMessage( + params.address, + params.message, + params.signature, + ); + } + case RpcMethod.SignRewardsMessage: { + assert(params, SignRewardsMessageRequest); + return this.#signRewardsMessage(params.accountId, params.message); + } + case RpcMethod.SignProofOfOwnership: { + assert(params, SignProofOfOwnershipRequest); + return this.#signProofOfOwnership(params.accountId, params.message); + } + + default: + throw new InexistentMethodError(`Method not found: ${method}`); + } + } + + async #executeSendFlow( + account: string, + origin: string, + ): Promise { + const psbt = await this.#sendFlowUseCases.display(account); + if (!psbt) { + return null; + } + const { txid, canBeMalleable } = await this.#accountUseCases.signPsbt( + account, + psbt, + origin, + { fill: false, broadcast: true }, + ); + if (!txid) { + throw new AssertionError('Missing transaction ID'); + } + if (canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); + } + + return { transactionId: txid.toString(), canBeMalleable }; + } + + async #signAndSend( + accountId: string, + transaction: string, + origin: string, + ): Promise { + const psbt = parsePsbt(transaction); + + const { txid, canBeMalleable } = await this.#accountUseCases.signPsbt( + accountId, + psbt, + origin, + { + fill: true, + broadcast: true, + }, + ); + if (!txid) { + throw new AssertionError('Missing transaction ID'); + } + if (canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); + } + + return { transactionId: txid.toString(), canBeMalleable }; + } + + async #computeFee( + accountId: string, + transaction: string, + scope: BtcScope, + ): Promise { + const psbt = parsePsbt(transaction); + const amount = await this.#accountUseCases.computeFee(accountId, psbt); + + return [mapToTransactionFees(amount, scopeToNetwork[scope])]; + } + + async #onAddressInput( + request: OnAddressInputRequest, + ): Promise { + const { value, accountId } = request; + + try { + // get the scope of the account so we can validate the address against the + // appropriate network (e.g. mainnet, testnet etc) + const bitcoinAccount = await this.#accountUseCases.get(accountId); + + return validateAddress(value, bitcoinAccount.network, this.#logger); + } catch (error) { + // We do not track this error as it is a user input. + + this.#logger.error( + `Invalid account. Error: %s`, + (error as CodifiedError).message, + ); + + return INVALID_RESPONSE; + } + } + + async #onAmountInput( + request: OnAmountInputRequest, + ): Promise { + const { value, accountId } = request; + + const amountValidation = validateAmount(value); + if (!amountValidation.valid) { + return amountValidation; + } + + try { + const bitcoinAccount = await this.#accountUseCases.get(accountId); + const dustValidation = validateDustLimit(value, bitcoinAccount); + if (!dustValidation.valid) { + return dustValidation; + } + const balanceValidation = validateAccountBalance(value, bitcoinAccount); + + return balanceValidation.valid ? NO_ERRORS_RESPONSE : balanceValidation; + } catch (error) { + // We do not track this error as it is a user input. + + this.#logger.error( + 'An error occurred: %s', + (error as CodifiedError).message, + ); + return INVALID_RESPONSE; + } + } + + #verifyMessage( + address: string, + message: string, + signature: string, + ): { valid: boolean } { + try { + const valid = Verifier.verifySignature(address, message, signature); + return { valid }; + } catch (error) { + throw new ValidationError( + 'Failed to verify signature', + { address, message, signature }, + error, + ); + } + } + + async #confirmSend(request: ConfirmSendRequest): Promise { + try { + const account = await this.#accountUseCases.get(request.fromAccountId); + + const inputValidation = + validateAmount(request.amount).valid && + validateAddress(request.toAddress, account.network, this.#logger) + .valid && + validateDustLimit(request.amount, account).valid; + + if (!inputValidation) { + return INVALID_RESPONSE; + } + + const balanceValidation = validateAccountBalance(request.amount, account); + + if (!balanceValidation.valid) { + return balanceValidation; + } + + const transaction = await this.#sendFlowUseCases.confirmSendFlow( + account, + request.amount, + request.toAddress, + ); + return mapPsbtToTransaction(account, transaction); + } catch (error) { + const { message } = error as CodifiedError; + + // we have tested for account balance earlier so if we get + // and insufficient funds message when trying to sign the PBST + // it will be because of insufficient fees + if (message.includes('Insufficient funds')) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + + const errorMessage = (error as CodifiedError).message; + this.#logger.error('An error occurred: %s', errorMessage); + + throw error; + } + } + + /** + * Handles the signing of a rewards message, of format 'rewards,{address},{timestamp}' base64 encoded. + * + * @param accountId - The ID of the account to sign with + * @param message - The base64-encoded rewards message + * @returns The signature + * @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account + */ + async #signRewardsMessage( + accountId: string, + message: string, + ): Promise<{ signature: string }> { + const { address: messageAddress } = parseRewardsMessage(message); + + const account = await this.#accountUseCases.get(accountId); + if (!account) { + throw new ValidationError('Account not found', { accountId }); + } + + const addressValidation = validateAddress( + messageAddress, + account.network, + this.#logger, + ); + if (!addressValidation.valid) { + throw new ValidationError( + `Invalid Bitcoin address in rewards message for network ${account.network}`, + { messageAddress, network: account.network }, + ); + } + + const accountAddress = account.publicAddress.toString(); + if (messageAddress !== accountAddress) { + throw new ValidationError( + `Address in rewards message (${messageAddress}) does not match signing account address (${accountAddress})`, + { messageAddress, accountAddress }, + ); + } + + const decodedMessage = atob(message); + + const signature = await this.#accountUseCases.signMessage( + accountId, + decodedMessage, + 'metamask', + { skipConfirmation: true }, + ); + + return { signature }; + } + + /** + * Handles the signing of a proof-of-ownership message, of format 'metamask:proof-of-ownership:{nonce}:{address}'. + * + * @param accountId - The ID of the account to sign with + * @param message - The plaintext proof-of-ownership message + * @returns The signature + * @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account + */ + async #signProofOfOwnership( + accountId: string, + message: string, + ): Promise<{ signature: string }> { + const { address: messageAddress } = parseProofOfOwnershipMessage(message); + + const account = await this.#accountUseCases.get(accountId); + if (!account) { + throw new ValidationError('Account not found', { accountId }); + } + + // Canonicalize before validating: BDK's `Address.from_string` rejects + // mixed-case bech32 per BIP-173, but bech32 addresses are + // case-insensitive on the wire and the canonical form is lowercase. + // Validating the raw message address would reject perfectly valid + // mixed-case input before we ever got a chance to normalize it. + const canonicalMessageAddress = canonicalizeBitcoinAddress(messageAddress); + const canonicalAccountAddress = canonicalizeBitcoinAddress( + account.publicAddress.toString(), + ); + + const addressValidation = validateAddress( + canonicalMessageAddress, + account.network, + this.#logger, + ); + if (!addressValidation.valid) { + throw new ValidationError( + `Invalid Bitcoin address in proof-of-ownership message for network ${account.network}`, + { messageAddress, network: account.network }, + ); + } + + if (canonicalMessageAddress !== canonicalAccountAddress) { + throw new ValidationError( + `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${canonicalAccountAddress})`, + { + messageAddress, + accountAddress: canonicalAccountAddress, + }, + ); + } + + const signature = await this.#accountUseCases.signMessage( + accountId, + message, + 'metamask', + { skipConfirmation: true }, + ); + + return { signature }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts new file mode 100644 index 00000000..f08eec3e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -0,0 +1,184 @@ +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { SendFormContext } from '../entities'; +import { + ConfirmationEvent, + ReviewTransactionEvent, + SendFormEvent, +} from '../entities'; +import type { ConfirmationUseCases, SendFlowUseCases } from '../use-cases'; +import { UserInputHandler } from './UserInputHandler'; + +describe('UserInputHandler', () => { + const mockSendFlowUseCases = mock(); + const mockConfirmationUseCases = mock(); + const mockContext = mock(); + + let handler: UserInputHandler; + + beforeEach(() => { + handler = new UserInputHandler( + mockSendFlowUseCases, + mockConfirmationUseCases, + ); + }); + + describe('route', () => { + it('throws error if missing context', async () => { + await expect( + handler.route( + 'interface-id', + { type: UserInputEventType.ButtonClickEvent } as UserInputEvent, + null, + ), + ).rejects.toThrow('Missing context'); + }); + + it('throws error if missing event name', async () => { + await expect( + handler.route( + 'interface-id', + { type: UserInputEventType.ButtonClickEvent } as UserInputEvent, + mockContext, + ), + ).rejects.toThrow('Missing event name'); + }); + + it('throws on unsupported event', async () => { + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: 'randomEvent', + }, + mockContext, + ), + ).rejects.toThrow('Unsupported event: randomEvent'); + }); + }); + + describe('update send form', () => { + it('executes on supported event', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.InputChangeEvent, + name: SendFormEvent.Recipient, + value: 'recipient-address', + }, + mockContext, + ); + + expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( + 'interface-id', + SendFormEvent.Recipient, + mockContext, + 'recipient-address', + ); + }); + + it('propagates errors from onChangeForm', async () => { + const error = new Error(); + mockSendFlowUseCases.onChangeForm.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: SendFormEvent.ClearRecipient, + }, + mockContext, + ), + ).rejects.toThrow(error); + }); + }); + + describe('update transaction review', () => { + it('executes on supported event', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ReviewTransactionEvent.Send, + }, + mockContext, + ); + + expect(mockSendFlowUseCases.onChangeReview).toHaveBeenCalledWith( + 'interface-id', + ReviewTransactionEvent.Send, + mockContext, + ); + }); + + it('propagates errors from onChangeReview', async () => { + const error = new Error(); + mockSendFlowUseCases.onChangeReview.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ReviewTransactionEvent.Send, + }, + mockContext, + ), + ).rejects.toThrow(error); + }); + }); + + describe('handle confirmation', () => { + it('executes on confirm', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Confirm, + }, + {}, + ); + + expect(mockConfirmationUseCases.onChange).toHaveBeenCalledWith( + 'interface-id', + ConfirmationEvent.Confirm, + ); + }); + + it('executes on cancel', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Cancel, + }, + {}, + ); + + expect(mockConfirmationUseCases.onChange).toHaveBeenCalledWith( + 'interface-id', + ConfirmationEvent.Cancel, + ); + }); + + it('propagates errors from onChange', async () => { + const error = new Error(); + mockConfirmationUseCases.onChange.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Cancel, + }, + {}, + ), + ).rejects.toThrow(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts new file mode 100644 index 00000000..94b1d5f9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -0,0 +1,76 @@ +import type { + InputChangeEvent, + Json, + UserInputEvent, +} from '@metamask/snaps-sdk'; + +import type { ReviewTransactionContext, SendFormContext } from '../entities'; +import { + ConfirmationEvent, + FormatError, + InexistentMethodError, + ReviewTransactionEvent, + SendFormEvent, +} from '../entities'; +import type { ConfirmationUseCases, SendFlowUseCases } from '../use-cases'; + +export class UserInputHandler { + readonly #sendFlowUseCases: SendFlowUseCases; + + readonly #confirmationUseCases: ConfirmationUseCases; + + constructor(sendFlow: SendFlowUseCases, confirmation: ConfirmationUseCases) { + this.#sendFlowUseCases = sendFlow; + this.#confirmationUseCases = confirmation; + } + + async route( + interfaceId: string, + event: UserInputEvent, + context: Record | null, + ): Promise { + if (!context) { + throw new FormatError('Missing context'); + } + if (!event.name) { + throw new FormatError('Missing event name'); + } + + if (this.#isSendFormEvent(event.name)) { + return this.#sendFlowUseCases.onChangeForm( + interfaceId, + event.name, + context as SendFormContext, + this.#hasValue(event) ? event.value : undefined, + ); + } else if (this.#isReviewTransactionEvent(event.name)) { + return this.#sendFlowUseCases.onChangeReview( + interfaceId, + event.name, + context as ReviewTransactionContext, + ); + } else if (this.#isConfirmationEvent(event.name)) { + return this.#confirmationUseCases.onChange(interfaceId, event.name); + } + + throw new InexistentMethodError(`Unsupported event: ${event.name}`); + } + + #isSendFormEvent(name: string): name is SendFormEvent { + return Object.values(SendFormEvent).includes(name as SendFormEvent); + } + + #isReviewTransactionEvent(name: string): name is ReviewTransactionEvent { + return Object.values(ReviewTransactionEvent).includes( + name as ReviewTransactionEvent, + ); + } + + #isConfirmationEvent(name: string): name is ConfirmationEvent { + return Object.values(ConfirmationEvent).includes(name as ConfirmationEvent); + } + + #hasValue(event: UserInputEvent): event is InputChangeEvent { + return 'value' in event; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts new file mode 100644 index 00000000..fc8ea620 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -0,0 +1,49 @@ +import type { AddressType, Network } from '@metamask/bitcoindevkit'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { enums } from 'superstruct'; + +const reverseMapping = < + From extends string | number | symbol, + // eslint-disable-next-line @typescript-eslint/naming-convention + To extends string | number | symbol, +>( + map: Record, +): Record => + Object.fromEntries(Object.entries(map).map(([from, to]) => [to, from])); + +export const scopeToNetwork: Record = { + [BtcScope.Mainnet]: 'bitcoin', + [BtcScope.Testnet]: 'testnet', + [BtcScope.Testnet4]: 'testnet4', + [BtcScope.Signet]: 'signet', + [BtcScope.Regtest]: 'regtest', +}; + +export const caipToAddressType: Record = { + [BtcAccountType.P2pkh]: 'p2pkh', + [BtcAccountType.P2sh]: 'p2sh', + [BtcAccountType.P2wpkh]: 'p2wpkh', + [BtcAccountType.P2tr]: 'p2tr', +}; + +export const networkToScope = reverseMapping(scopeToNetwork); + +export const addressTypeToCaip = reverseMapping(caipToAddressType); + +export enum Caip19Asset { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', + Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0', + Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:0', + Regtest = 'bip122:regtest/slip44:0', +} + +export const NetworkStruct = enums(Object.values(BtcScope)); + +export const networkToCaip19: Record = { + bitcoin: Caip19Asset.Bitcoin, + testnet: Caip19Asset.Testnet, + testnet4: Caip19Asset.Testnet4, + signet: Caip19Asset.Signet, + regtest: Caip19Asset.Regtest, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts new file mode 100644 index 00000000..636c5714 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts @@ -0,0 +1,14 @@ +import type { Network } from '@metamask/bitcoindevkit'; + +export const networkToIcon: Record = { + bitcoin: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNmNzkzMWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + testnet: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiM1ZmQxNWMiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + testnet4: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiM1ZmQxNWMiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + signet: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNiMDI4YWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + regtest: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNiMDI4YWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts new file mode 100644 index 00000000..ccde4f44 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -0,0 +1,6 @@ +export * from './KeyringHandler'; +export * from './CronHandler'; +export * from './RpcHandler'; +export * from './UserInputHandler'; +export * from './AssetsHandler'; +export * from './caip'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts new file mode 100644 index 00000000..4394759e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts @@ -0,0 +1,339 @@ +import type { + Transaction, + TxOut, + ScriptBuf, + Amount, + Txid, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; +import { TransactionStatus, FeeType } from '@metamask/keyring-api'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from '../entities'; +import { Caip19Asset } from './caip'; +import { mapPsbtToTransaction } from './mappings'; + +// Mock the entire bitcoindevkit module +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_script: jest.fn(), + }, +})); + +describe('mapPsbtToTransaction', () => { + const ACCOUNT_ID = '724ac464-6572-4d9c-a8e2-4075c8846d65'; + const TIMESTAMP = 1234567890; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(TIMESTAMP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** + * Creates a mock transaction with the specified txid and outputs. + * + * @param txid - The transaction ID as a string. + * @param outputs - Array of transaction outputs. + * @returns A mocked Transaction object. + */ + function createMockTransaction(txid: string, outputs: TxOut[]) { + const mockTxid = mock(); + jest.spyOn(mockTxid, 'toString').mockReturnValue(txid); + + return mock({ + compute_txid: () => mockTxid, + output: outputs, + }); + } + + /** + * Creates a mock transaction output with the specified amount. + * + * @param satoshis - The amount in satoshis. + * @returns A mocked TxOut object. + */ + function createMockOutput(satoshis: number) { + const mockAmount = mock(); + jest.spyOn(mockAmount, 'to_btc').mockReturnValue(satoshis / 100_000_000); + + const mockScript = mock(); + jest.spyOn(mockScript, 'is_op_return').mockReturnValue(false); + + return mock({ + script_pubkey: mockScript, + value: mockAmount, + }); + } + + /** + * Creates a mock Bitcoin account. + * + * @param network - The network type ('bitcoin' or 'testnet'). + * @param feeSatoshis - The fee amount in satoshis. + * @param addressType - The account's address type (script type). + * @returns A mocked BitcoinAccount object. + */ + function createMockAccount( + network: 'bitcoin' | 'testnet' = 'bitcoin', + feeSatoshis = 500, + addressType: 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr' = 'p2wpkh', + ) { + const mockFeeAmount = mock(); + jest + .spyOn(mockFeeAmount, 'to_btc') + .mockReturnValue(feeSatoshis / 100_000_000); + + const account = mock(); + account.id = ACCOUNT_ID; + account.network = network; + account.addressType = addressType; + jest.spyOn(account, 'calculateFee').mockReturnValue(mockFeeAmount); + jest.spyOn(account, 'isMine').mockReturnValue(false); + + return account; + } + + it('maps a bitcoin transaction with single recipient', () => { + const txId = 'abc123def456789'; + const account = createMockAccount(); + const output = createMockOutput(10000); + const transaction = createMockTransaction(txId, [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result).toStrictEqual({ + type: 'send', + id: txId, + account: ACCOUNT_ID, + chain: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin mainnet CAIP-2 + status: TransactionStatus.Unconfirmed, + timestamp: TIMESTAMP, + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: TIMESTAMP, + }, + ], + to: [ + { + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + asset: { + amount: '0.0001', // BTC amount, not sats + fungible: true, + unit: 'BTC', + type: Caip19Asset.Bitcoin, + }, + }, + ], + from: [], + fees: [ + { + type: FeeType.Priority, + asset: { + amount: '0.000005', // BTC amount, not sats + fungible: true, + unit: 'BTC', + type: Caip19Asset.Bitcoin, + }, + }, + ], + canBeMalleable: false, + }); + }); + + it('sets canBeMalleable=true for legacy P2PKH accounts', () => { + const account = createMockAccount('bitcoin', 500, 'p2pkh'); + const output = createMockOutput(10000); + const transaction = createMockTransaction('p2pkhtx', [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.canBeMalleable).toBe(true); + }); + + it('sets canBeMalleable=false for BIP49 wrapped-segwit (p2sh) accounts', () => { + // BIP49 sh(wpkh(...)) keeps signatures in witness, not scriptSig. + const account = createMockAccount('bitcoin', 500, 'p2sh'); + const output = createMockOutput(10000); + const transaction = createMockTransaction('p2shtx', [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.canBeMalleable).toBe(false); + }); + + it('filters out change outputs owned by the account', () => { + const account = createMockAccount(); + const changeOutput = createMockOutput(5000); + const recipientOutput = createMockOutput(10000); + const transaction = createMockTransaction('def456abc123', [ + changeOutput, + recipientOutput, + ]); + + // First output is change (owned by account), second is recipient + jest + .spyOn(account, 'isMine') + .mockReturnValueOnce(true) // change output + .mockReturnValueOnce(false); // recipient output + + // Only mock for the recipient since change output won't call Address.from_script + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + // Should only include the recipient output + expect(result.to).toHaveLength(1); + expect(result.to[0]?.address).toBe( + 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + ); + const asset = result.to[0]?.asset; + expect(asset?.fungible).toBe(true); + expect((asset as any).amount).toBe('0.0001'); + }); + + it('uses testnet chain ID and tBTC unit for testnet', () => { + const account = createMockAccount('testnet', 300); // 300 sats fee + const output = createMockOutput(10000); + const transaction = createMockTransaction('testnet123', [output]); + + // Mock Address.from_script for testnet address + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => + 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.chain).toBe('bip122:000000000933ea01ad0ee984209779ba'); // Testnet CAIP-2 + + const recipientAsset = result.to[0]?.asset; + expect(recipientAsset?.fungible).toBe(true); + expect((recipientAsset as any).unit).toBe('tBTC'); + expect((recipientAsset as any).type).toBe(Caip19Asset.Testnet); + + const feeAsset = result.fees[0]?.asset; + expect(feeAsset?.fungible).toBe(true); + expect((feeAsset as any).unit).toBe('tBTC'); + expect((feeAsset as any).type).toBe(Caip19Asset.Testnet); + expect((feeAsset as any).amount).toBe('0.000003'); + }); + + it('includes multiple recipients', () => { + const account = createMockAccount(); + const output1 = createMockOutput(10000); + const output2 = createMockOutput(20000); + const output3 = createMockOutput(5000); + const transaction = createMockTransaction('multi123', [ + output1, + output2, + output3, + ]); + + jest + .mocked(Address.from_script) + .mockImplementationOnce( + () => ({ toString: () => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' }) as any, + ) + .mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ) + .mockImplementationOnce( + () => + ({ + toString: () => 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.to).toHaveLength(3); + expect(result.to[0]?.address).toBe('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); + expect(result.to[1]?.address).toBe( + 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + ); + expect(result.to[2]?.address).toBe( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + ); + + // Check amounts for each output + const amounts = ['0.0001', '0.0002', '0.00005']; + result.to.forEach((recipient, index) => { + expect(recipient).toBeDefined(); + const asset = recipient?.asset; + expect(asset?.fungible).toBe(true); + expect((asset as any).amount).toBe(amounts[index]); + }); + }); + + it('handles transactions with no recipients (all change or empty)', () => { + const account = createMockAccount(); + + // Test empty outputs + const emptyTx = createMockTransaction('empty123', []); + const emptyResult = mapPsbtToTransaction(account, emptyTx); + expect(emptyResult.to).toStrictEqual([]); + + // Test all outputs being change + const changeOutput = createMockOutput(10000); + const changeOnlyTx = createMockTransaction('change123', [changeOutput]); + jest.spyOn(account, 'isMine').mockReturnValue(true); + + const changeOnlyResult = mapPsbtToTransaction(account, changeOnlyTx); + expect(changeOnlyResult.to).toStrictEqual([]); + }); + + it('calculates and includes transaction fees', () => { + const account = createMockAccount('bitcoin', 2500); // 2500 sats fee + const output = createMockOutput(50000); + const transaction = createMockTransaction('fee123', [output]); + + jest + .mocked(Address.from_script) + .mockImplementationOnce(() => ({ toString: () => 'bc1qtest' }) as any); + + const result = mapPsbtToTransaction(account, transaction); + + expect(account.calculateFee).toHaveBeenCalledWith(transaction); + const feeAsset = result.fees[0]?.asset; + expect(feeAsset?.fungible).toBe(true); + expect((feeAsset as any).amount).toBe('0.000025'); // 2500 sats = 0.000025 BTC + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts new file mode 100644 index 00000000..6f90bea7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -0,0 +1,318 @@ +import type { + Amount, + ChainPosition, + LocalOutput, + Network, + Transaction, + TxOut, + WalletTx, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; +import type { + DiscoveredAccount, + KeyringAccount, + Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; +import { + DiscoveredAccountType, + FeeType, + TransactionStatus, +} from '@metamask/keyring-api'; + +import { + type BitcoinAccount, + canAccountTxidBeMalleated, + networkToCurrencyUnit, +} from '../entities'; +import type { Caip19Asset } from './caip'; +import { addressTypeToCaip, networkToCaip19, networkToScope } from './caip'; + +export type TransactionFee = { + type: FeeType; + asset: TransactionAmount; +}; + +export type TransactionAmount = { + amount: string; + fungible: true; + unit: string; + type: Caip19Asset; +}; + +type TransactionRecipient = { + address: string; + asset: TransactionAmount; +}; + +type TransactionEvent = { + status: TransactionStatus; + timestamp: number | null; +}; + +export type Utxo = { + // Outpoint of the utxo in the format : + outpoint: string; + // Value of output in satoshis + value: string; + derivationIndex: number; + // scriptPubley in ASM format + scriptPubkey: string; + scriptPubkeyHex: string; + // If the script can be represented as an address, omitted otherwise + address?: string; +}; + +/** + * Maps a Bitcoin Account to a Keyring Account. + * + * @param account - The Bitcoin account. + * @returns The Keyring account. + */ +export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { + return { + type: addressTypeToCaip[account.addressType] as KeyringAccount['type'], + scopes: [networkToScope[account.network]], + id: account.id, + address: account.publicAddress.toString(), + options: { + entropySource: account.entropySource, // TODO: Legacy field. To be removed once multichain accounts are out. + exportable: false, + entropy: { + type: 'mnemonic', + id: account.entropySource, + derivationPath: `m/${account.derivationPath.slice(1).join('/')}`, + groupIndex: account.accountIndex, + }, + }, + methods: account.capabilities, + }; +} + +const mapToAmount = (amount: Amount, network: Network): TransactionAmount => { + return { + amount: amount.to_btc().toString(), + fungible: true, + unit: networkToCurrencyUnit[network], + type: networkToCaip19[network], + }; +}; + +const mapToAssetMovement = ( + output: TxOut, + network: Network, +): TransactionRecipient | null => { + if (output.script_pubkey.is_op_return()) { + return null; + } + + try { + return { + address: Address.from_script(output.script_pubkey, network).toString(), + asset: mapToAmount(output.value, network), + }; + } catch { + // We do not track this error as non-address scripts are expected here + return null; + } +}; + +const mapToEvents = ( + chainPosition: ChainPosition, +): [TransactionEvent[], number | null, TransactionStatus] => { + let timestamp = chainPosition.last_seen + ? Number(chainPosition.last_seen) + : null; + let status = TransactionStatus.Unconfirmed; + const events: TransactionEvent[] = [ + { + status, + timestamp, + }, + ]; + if (chainPosition.anchor) { + timestamp = Number(chainPosition.anchor.confirmation_time); + status = TransactionStatus.Confirmed; + events.push({ + status, + timestamp, + }); + } + return [events, timestamp, status]; +}; + +/** + * Maps an amount & network to a Fee struct + * + * @param amount The fee amount + * @param network The network relevant to the fee amount + * + * @returns The transaction fee object + */ +export function mapToTransactionFees( + amount: Amount, + network: Network, +): TransactionFee { + return { + type: FeeType.Priority, + asset: mapToAmount(amount, network), + }; +} + +/** + * Maps a Bitcoin Transaction to a Keyring Transaction. + * + * @param account - The account account. + * @param walletTx - The Bitcoin transaction managed by this account. + * @returns The Keyring transaction. + */ +export function mapToTransaction( + account: BitcoinAccount, + walletTx: WalletTx, +): KeyringTransaction { + const { tx, chain_position: chainPosition, txid } = walletTx; + const { network } = account; + + const [events, timestamp, status] = mapToEvents(chainPosition); + const [sent] = account.sentAndReceived(tx); + const isSend = sent.to_btc() > 0; + + const transaction: KeyringTransaction = { + type: isSend ? 'send' : 'receive', + id: txid.toString(), + account: account.id, + chain: networkToScope[network], + status, + timestamp, + events, + to: [], + from: [], + fees: isSend + ? [mapToTransactionFees(account.calculateFee(tx), network)] + : [], + }; + + // If it's a Send transaction: + // - to: all the outputs discarding the change (so it also works for consolidations). + // - from: empty as irrelevant because we might be sending from multiple addresses. Sufficient to say "Sent from Bitcoin Account". + // If it's a Receive transaction: + // - to: all the outputs spending to addresses we own. + // - from: empty as irrevelant because we might have hundreds of inputs in a tx. Point to explorer for details. + if (isSend) { + for (const txout of tx.output) { + if (!account.isMine(txout.script_pubkey)) { + const recipient = mapToAssetMovement(txout, network); + if (recipient) { + transaction.to.push(recipient); + } + } + } + } else { + for (const txout of tx.output) { + if (account.isMine(txout.script_pubkey)) { + const recipient = mapToAssetMovement(txout, network); + if (recipient) { + transaction.to.push(recipient); + } + } + } + } + + return transaction; +} + +/** + * KeyringTransaction augmented with a malleability flag. The base + * `KeyringTransaction` shape from `@metamask/keyring-api` does not include + * `canBeMalleable`; consumers (extension, dApp) that ignore unknown fields + * will see the standard shape, while consumers that opt in can read the flag + * to decide whether to trust the txid before block confirmation. + */ +export type KeyringTransactionWithMalleability = KeyringTransaction & { + canBeMalleable: boolean; +}; + +/** + * Maps a PSBT to a Keyring Transaction with a malleability flag. + * + * @param account - The Bitcoin account. + * @param tx - The extracted transaction from the PSBT. + * @returns The Keyring transaction. + */ +export function mapPsbtToTransaction( + account: BitcoinAccount, + tx: Transaction, +): KeyringTransactionWithMalleability { + const txid = tx.compute_txid(); + const currentTime = Date.now(); + + const getRecipients = (txOut: TxOut[]): TransactionRecipient[] => { + return txOut.flatMap((output) => { + if (account.isMine(output.script_pubkey)) { + return []; + } + const recipient = mapToAssetMovement(output, account.network); + return recipient ? [recipient] : []; + }); + }; + + return { + type: 'send', + id: txid.toString(), + account: account.id, + chain: networkToScope[account.network], + status: TransactionStatus.Unconfirmed, + timestamp: currentTime, + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: currentTime, + }, + ], + to: getRecipients(tx.output), + from: [], + fees: [mapToTransactionFees(account.calculateFee(tx), account.network)], + canBeMalleable: canAccountTxidBeMalleated(account.addressType), + }; +} + +/** + * Maps a Bitcoin Account to a Discovered Account. + * + * @param account - The Bitcoin account. + * @returns The Discovered account. + */ +export function mapToDiscoveredAccount( + account: BitcoinAccount, +): DiscoveredAccount { + return { + type: DiscoveredAccountType.Bip44, + scopes: [networkToScope[account.network]], + derivationPath: `m/${account.derivationPath.slice(1).join('/')}`, + }; +} + +/** + * Maps a Bitcoin UTXO to a Keyring UTXO. + * + * @param utxo - The wallet UTXO. + * @param network - The network of the UTXO. + * @returns The Keyring UTXO. + */ +export function mapToUtxo(utxo: LocalOutput, network: Network): Utxo { + let address: Address | undefined; + try { + address = Address.from_script(utxo.txout.script_pubkey, network); + } catch { + // We do not track this error as the address field is explicitly optional in Utxo + address = undefined; + } + + return { + derivationIndex: utxo.derivation_index, + outpoint: utxo.outpoint.toString(), + value: utxo.txout.value.to_sat().toString(), + scriptPubkey: utxo.txout.script_pubkey.to_asm_string(), + scriptPubkeyHex: utxo.txout.script_pubkey.to_hex_string(), + address: address?.toString(), + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts new file mode 100644 index 00000000..3b96a918 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts @@ -0,0 +1,18 @@ +import { Psbt } from '@metamask/bitcoindevkit'; + +import { FormatError } from '../entities'; + +/** + * Parses a PSBT encoded as a base64 string into a PSBT object. + * + * @param psbtBase64 - A PSBT encoded as a base64 string + * @returns A PSBT object + * @throws {FormatError} If the PSBT is invalid. + */ +export function parsePsbt(psbtBase64: string): Psbt { + try { + return Psbt.from_string(psbtBase64); + } catch (error) { + throw new FormatError('Invalid PSBT', { transaction: psbtBase64 }, error); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts new file mode 100644 index 00000000..1a9dbebd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts @@ -0,0 +1,11 @@ +import type { Infer } from 'superstruct'; + +import type { + ConfirmSendRequestStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, +} from './validation'; + +export type OnAddressInputRequest = Infer; +export type OnAmountInputRequest = Infer; +export type ConfirmSendRequest = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts new file mode 100644 index 00000000..f90d1703 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts @@ -0,0 +1,409 @@ +import type { AddressType } from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { BitcoinAccount } from '../entities'; +import { + canonicalizeBitcoinAddress, + ComputeFeeRequest, + FillPsbtRequest, + parseProofOfOwnershipMessage, + parseRewardsMessage, + SendTransferRequest, + SignPsbtRequest, + validateDustLimit, + validateSelectedAccounts, +} from './validation'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_string: jest.fn(), + }, + Amount: { + from_btc: jest.fn(), + }, +})); + +describe('validation', () => { + describe('feeRate request validation', () => { + const account = { account: { address: 'test-account-address' } }; + const optionalFeeRate = (feeRate?: number) => + feeRate === undefined ? {} : { feeRate }; + const requestCases: { + name: string; + assertParams: (feeRate?: number) => void; + }[] = [ + { + name: 'SignPsbtRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + options: { fill: false, broadcast: true }, + }, + SignPsbtRequest, + ), + }, + { + name: 'FillPsbtRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + }, + FillPsbtRequest, + ), + }, + { + name: 'ComputeFeeRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + }, + ComputeFeeRequest, + ), + }, + { + name: 'SendTransferRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + recipients: [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ], + ...optionalFeeRate(feeRate), + }, + SendTransferRequest, + ), + }, + ]; + + describe.each(requestCases)('$name', ({ assertParams }) => { + it('accepts an omitted feeRate', () => { + expect(() => assertParams()).not.toThrow(); + }); + + it.each([1, 2.4, 3])('accepts feeRate %p', (feeRate) => { + expect(() => assertParams(feeRate)).not.toThrow(); + }); + + it.each([ + ['zero', 0], + ['below minimum', 0.5], + ['negative', -5], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ])('rejects %s feeRate', (_description, feeRate) => { + expect(() => assertParams(feeRate)).toThrow('At path: feeRate'); + }); + }); + }); + + describe('validateDustLimit', () => { + const makeAccount = (addressType: AddressType) => + mock({ addressType }); + + it.each<{ type: AddressType; dust: bigint }>([ + { type: 'p2pkh', dust: 546n }, + { type: 'p2sh', dust: 540n }, + { type: 'p2wsh', dust: 330n }, + { type: 'p2tr', dust: 330n }, + { type: 'p2wpkh', dust: 294n }, + ])( + 'returns valid for amount above dust limit for $type', + ({ type, dust }) => { + const amountBtc = (Number(dust) / 1e8 + 0.00000001).toFixed(8); + const mockFromBtc = { to_sat: () => dust + 1n }; + const { Amount } = jest.requireMock('@metamask/bitcoindevkit'); + Amount.from_btc.mockReturnValue(mockFromBtc); + + const result = validateDustLimit(amountBtc, makeAccount(type)); + expect(result.valid).toBe(true); + }, + ); + + it('returns invalid for amount below dust limit', () => { + const mockFromBtc = { to_sat: () => 100n }; + const { Amount } = jest.requireMock('@metamask/bitcoindevkit'); + Amount.from_btc.mockReturnValue(mockFromBtc); + + const result = validateDustLimit('0.000001', makeAccount('p2pkh')); + expect(result.valid).toBe(false); + }); + }); + + describe('validateSelectedAccounts', () => { + it('does not throw when all account IDs exist', () => { + expect(() => + validateSelectedAccounts(new Set(['a', 'b']), ['a', 'b', 'c']), + ).not.toThrow(); + }); + + it('throws when an account ID does not exist', () => { + expect(() => + validateSelectedAccounts(new Set(['a', 'x']), ['a', 'b']), + ).toThrow('Account IDs were not part of existing accounts.'); + }); + }); + + describe('parseRewardsMessage', () => { + const validBitcoinMainnetAddress = + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const currentTimestamp = Math.floor(Date.now() / 1000); + + const toBase64 = (utf8: string): string => btoa(utf8); + + describe('valid parsing', () => { + it('correctly extracts address and timestamp from valid message', () => { + const expectedAddress = validBitcoinMainnetAddress; + const expectedTimestamp = 1736660000; + const utf8Message = `rewards,${expectedAddress},${expectedTimestamp}`; + const base64Message = toBase64(utf8Message); + + const result = parseRewardsMessage(base64Message); + + expect(result.address).toBe(expectedAddress); + expect(result.timestamp).toBe(expectedTimestamp); + }); + }); + + describe('invalid messages', () => { + it('rejects string that decodes but not rewards format', () => { + const base64Message = 'hello world'; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + + it('rejects empty string', () => { + const base64Message = ''; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + + it('rejects invalid base64 with special characters', () => { + const base64Message = '!!!@@@###'; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Invalid base64 encoding', + ); + }); + }); + + describe('invalid message prefix', () => { + it.each([ + { + message: `reward,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: "missing 's'", + }, + { + message: `Rewards,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'wrong case (capitalized)', + }, + { + message: `bonus,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'wrong prefix', + }, + { + message: `${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'no prefix', + }, + { + message: `REWARDS,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'all caps', + }, + ])( + 'rejects message that does not start with "rewards,": $description', + ({ message: utf8Message }) => { + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }, + ); + }); + + describe('invalid message structure', () => { + it('rejects message with only prefix', () => { + const utf8Message = 'rewards,'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message missing timestamp', () => { + const utf8Message = `rewards,${validBitcoinMainnetAddress}`; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message with too many parts', () => { + const utf8Message = `rewards,${validBitcoinMainnetAddress},${currentTimestamp},extra`; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message with empty parts', () => { + const utf8Message = 'rewards,,,'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow(/timestamp/iu); + }); + + it('rejects message with only prefix without comma', () => { + const utf8Message = 'rewards'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + }); + + describe('invalid timestamps', () => { + it.each([ + { + timestamp: 'invalid', + description: 'non-numeric', + }, + { + timestamp: '', + description: 'empty timestamp', + }, + { + timestamp: '-1', + description: 'negative timestamp', + }, + { + timestamp: '0', + description: 'zero timestamp', + }, + { + timestamp: '123.456', + description: 'decimal timestamp', + }, + { + timestamp: '1.0', + description: 'decimal with .0', + }, + { + timestamp: 'abc123', + description: 'alphanumeric', + }, + ])( + 'rejects message with invalid timestamp: $description', + ({ timestamp }) => { + const utf8Message = `rewards,${validBitcoinMainnetAddress},${timestamp}`; + const base64Message = toBase64(utf8Message); + + expect(() => parseRewardsMessage(base64Message)).toThrow( + /timestamp/iu, + ); + }, + ); + }); + }); + + describe('parseProofOfOwnershipMessage', () => { + const address = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + it('extracts the nonce and address from a valid message', () => { + const result = parseProofOfOwnershipMessage( + `metamask:proof-of-ownership:${nonce}:${address}`, + ); + expect(result).toStrictEqual({ nonce, address }); + }); + + it('preserves embedded colons in the nonce (split on last ":")', () => { + const colonNonce = 'ns:abc:123'; + const result = parseProofOfOwnershipMessage( + `metamask:proof-of-ownership:${colonNonce}:${address}`, + ); + expect(result).toStrictEqual({ nonce: colonNonce, address }); + }); + + it.each([ + `rewards,${address},123`, + `metamask:proof:${nonce}:${address}`, + `Metamask:proof-of-ownership:${nonce}:${address}`, + `${nonce}:${address}`, + '', + ])('rejects messages without the expected prefix: "%s"', (message) => { + expect(() => parseProofOfOwnershipMessage(message)).toThrow( + 'Message must start with "metamask:proof-of-ownership:"', + ); + }); + + it('rejects messages missing the address separator', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership:${nonce}`), + ).toThrow('Message must follow the format'); + }); + + it('rejects messages with an empty nonce', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership::${address}`), + ).toThrow('non-empty nonce'); + }); + + it('rejects messages with an empty address', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership:${nonce}:`), + ).toThrow('non-empty address'); + }); + }); + + describe('canonicalizeBitcoinAddress', () => { + it('lowercases bech32 P2WPKH addresses', () => { + expect( + canonicalizeBitcoinAddress( + 'BC1QAR0SRRR7XFKVY5L643LYDNW9RE59GTZZWF5MDQ', + ), + ).toBe('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'); + }); + + it('lowercases bech32m P2TR addresses', () => { + expect( + canonicalizeBitcoinAddress( + 'BC1PMFR3P9J00PFXJH0ZMGP99Y8ZFTMD3S5PMEDQHYPTWY6LYLEX2GYS2WAMVS', + ), + ).toBe('bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lylex2gys2wamvs'); + }); + + it.each(['tb1q...', 'TB1Q...', 'bcrt1q...', 'BCRT1Q...'])( + 'lowercases bech32 testnet and regtest addresses: "%s"', + (input) => { + expect(canonicalizeBitcoinAddress(input)).toBe(input.toLowerCase()); + }, + ); + + it('leaves legacy base58check P2PKH addresses unchanged', () => { + expect( + canonicalizeBitcoinAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'), + ).toBe('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); + }); + + it('leaves legacy base58check P2SH addresses unchanged', () => { + expect( + canonicalizeBitcoinAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'), + ).toBe('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts new file mode 100644 index 00000000..8dbfda3b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -0,0 +1,470 @@ +import type { Network, AddressType } from '@metamask/bitcoindevkit'; +import { Address, Amount } from '@metamask/bitcoindevkit'; +import { BtcMethod } from '@metamask/keyring-api'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import type { Infer } from 'superstruct'; +import { + pattern, + array, + boolean, + enums, + literal, + number, + object, + optional, + string, + nonempty, + refine, + union, + is, +} from 'superstruct'; + +import type { BitcoinAccount, CodifiedError, Logger } from '../entities'; +import { ValidationError } from '../entities'; + +export enum RpcMethod { + StartSendTransactionFlow = 'startSendTransactionFlow', + SignAndSendTransaction = 'signAndSendTransaction', + ComputeFee = 'computeFee', + VerifyMessage = 'verifyMessage', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', + ConfirmSend = 'confirmSend', + SignRewardsMessage = 'signRewardsMessage', + SignProofOfOwnership = 'signProofOfOwnership', +} + +export enum SendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalance = 'InsufficientBalance', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', +} + +export const NonEmptyStringStruct = refine( + nonempty(string()), + 'non-whitespace string', + (value) => value.trim().length > 0, +); + +export const UuidStruct = pattern( + NonEmptyStringStruct, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, +); + +export const OnAddressInputRequestStruct = object({ + value: NonEmptyStringStruct, + accountId: UuidStruct, +}); + +export const OnAmountInputRequestStruct = object({ + value: NonEmptyStringStruct, + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, +}); + +export const ConfirmSendRequestStruct = object({ + fromAccountId: UuidStruct, + toAddress: NonEmptyStringStruct, + assetId: CaipAssetTypeStruct, + amount: NonEmptyStringStruct, +}); + +export const ValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendErrorCodes)), + }), + ), +}); + +export type ValidationResponse = Infer; + +// create constants for the two most common responses +export const INVALID_RESPONSE: ValidationResponse = { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], +}; + +export const NO_ERRORS_RESPONSE: ValidationResponse = { + valid: true, + errors: [], +}; + +/** + * Wallet account struct for Bitcoin requests. + */ +const WalletAccountStruct = object({ + address: string(), +}); + +export const FeeRateStruct = refine( + number(), + 'fee rate greater than or equal to 1', + (value) => Number.isFinite(value) && value >= 1, +); + +export const SignPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(FeeRateStruct), + options: object({ + fill: boolean(), + broadcast: boolean(), + }), +}); + +export const ComputeFeeRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(FeeRateStruct), +}); + +export const BroadcastPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), +}); + +export const FillPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(FeeRateStruct), +}); + +export const SendTransferRequest = object({ + account: WalletAccountStruct, + recipients: array( + object({ + address: string(), + amount: string(), + }), + ), + feeRate: optional(FeeRateStruct), +}); + +export const GetUtxoRequest = object({ + account: WalletAccountStruct, + outpoint: string(), +}); + +export const SignMessageRequest = object({ + account: WalletAccountStruct, + message: string(), +}); + +/** + * Validates that a JsonRpcRequest is a valid Bitcoin request. + * + * TODO: update btc-methods.md to include all the new methods + * + * @see https://github.com/MetaMask/accounts/blob/main/packages/keyring-api/docs/btc-methods.md + */ +export const SignPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.SignPsbt), + params: SignPsbtRequest, +}); + +export const FillPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.FillPsbt), + params: FillPsbtRequest, +}); + +export const ComputeFeeKeyringRequestStruct = object({ + method: literal(BtcMethod.ComputeFee), + params: ComputeFeeRequest, +}); + +export const BroadcastPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.BroadcastPsbt), + params: BroadcastPsbtRequest, +}); + +export const SendTransferKeyringRequestStruct = object({ + method: literal(BtcMethod.SendTransfer), + params: SendTransferRequest, +}); + +export const GetUtxoKeyringRequestStruct = object({ + method: literal(BtcMethod.GetUtxo), + params: GetUtxoRequest, +}); + +export const SignMessageKeyringRequestStruct = object({ + method: literal(BtcMethod.SignMessage), + params: SignMessageRequest, +}); + +export const BtcWalletRequestStruct = union([ + SignPsbtKeyringRequestStruct, + FillPsbtKeyringRequestStruct, + ComputeFeeKeyringRequestStruct, + BroadcastPsbtKeyringRequestStruct, + SendTransferKeyringRequestStruct, + GetUtxoKeyringRequestStruct, + SignMessageKeyringRequestStruct, +]); + +/** + * Validates that an amount is a positive number + * + * @param amount - The amount to validate + * @returns ValidationResponse indicating if the amount is valid + */ +export function validateAmount(amount: string): ValidationResponse { + const valueToNumber = Number(amount); + if (!Number.isFinite(valueToNumber) || valueToNumber <= 0) { + return INVALID_RESPONSE; + } + return NO_ERRORS_RESPONSE; +} + +/** + * Validates a Bitcoin address for a specific network + * + * @param address - The address to validate + * @param network - The Bitcoin network + * @param logger - Optional logger for error logging + * @returns ValidationResponse indicating if the address is valid + */ +export function validateAddress( + address: string, + network: Network, + logger?: Logger, +): ValidationResponse { + try { + Address.from_string(address, network).toString(); + return NO_ERRORS_RESPONSE; + } catch (error) { + if (logger) { + logger.error( + 'Invalid address for network %s. Error: %s', + network, + (error as CodifiedError).message, + ); + } + return INVALID_RESPONSE; + } +} + +/** + * Validates that an account has sufficient balance for a transaction + * + * @param amountInBtc - The amount in BTC + * @param account - The Bitcoin account + * @returns ValidationResponse indicating if the balance is sufficient + */ +export function validateAccountBalance( + amountInBtc: string, + account: BitcoinAccount, +): ValidationResponse { + const balance = account.balance.trusted_spendable; + const valueToNumber = Amount.from_btc(Number(amountInBtc)); + + if (valueToNumber.to_sat() > balance.to_sat()) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + return NO_ERRORS_RESPONSE; +} + +/** + * Validates that all account IDs are part of the existing accounts. + * + * @param accountIds - Set of account IDs to validate + * @param existingAccountIds - Array of existing account IDs + * @throws {ValidationError} If any account ID is not part of existing accounts + */ +export function validateSelectedAccounts( + accountIds: Set, + existingAccountIds: string[], +): void { + const isSubset = (first: Set, second: Set): boolean => { + return Array.from(first).every((element) => second.has(element)); + }; + + if (!isSubset(accountIds, new Set(existingAccountIds))) { + throw new ValidationError( + 'Account IDs were not part of existing accounts.', + ); + } +} + +/** + * Returns the dust limit (in satoshis) for a given address type. + * + * @param addressType - The account address type (script type). + * @returns The minimum spendable amount in satoshis for this script type. + */ +function getDustLimitSats(addressType: AddressType): bigint { + switch (addressType) { + case 'p2wpkh': + return 294n; + case 'p2pkh': + return 546n; + case 'p2sh': + return 540n; + case 'p2wsh': + return 330n; + case 'p2tr': + return 330n; + default: + return 546n; + } +} + +/** + * Validates that the amount is above the dust limit for the account's script type. + * + * @param amountInBtc - The amount to send, in BTC units. + * @param account - The Bitcoin account providing address type and context. + * @returns ValidationResponse indicating whether the amount meets dust requirements. + */ +export function validateDustLimit( + amountInBtc: string, + account: BitcoinAccount, +): ValidationResponse { + const sats = Amount.from_btc(Number(amountInBtc)).to_sat(); + const min = getDustLimitSats(account.addressType); + if (sats < min) { + return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + } + return NO_ERRORS_RESPONSE; +} + +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * Parses a base64-encoded rewards message in the format 'rewards,{address},{timestamp}' + * + * @param base64Message - The base64-encoded message to parse + * @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 + let decodedMessage: string; + try { + decodedMessage = atob(base64Message); + } catch { + throw new Error('Invalid base64 encoding'); + } + + // 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 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 as string, + timestamp, + }; +} + +export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; + +// bech32/bech32m HRPs for Bitcoin mainnet, testnet, and regtest. Addresses +// starting with one of these are case-insensitive but only canonical in +// lowercase. +const BECH32_BITCOIN_ADDRESS_PREFIXES = ['bc1', 'tb1', 'bcrt1']; + +/** + * Canonicalizes a Bitcoin address for equality comparison: bech32/bech32m + * (P2WPKH, P2WSH, P2TR) are lowercased, legacy base58check (P2PKH, P2SH) + * are passed through unchanged. + * + * @param address - The Bitcoin address to canonicalize + * @returns The canonical form of the address + */ +export function canonicalizeBitcoinAddress(address: string): string { + const lowercased = address.toLowerCase(); + if ( + BECH32_BITCOIN_ADDRESS_PREFIXES.some((hrp) => lowercased.startsWith(hrp)) + ) { + return lowercased; + } + return address; +} + +/** + * Parses a plaintext proof-of-ownership message in the format 'metamask:proof-of-ownership:{nonce}:{address}' + * + * @param message - The plaintext message to parse + * @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 (address === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty address', + ); + } + + return { nonce, address }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts new file mode 100644 index 00000000..7751ca0f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -0,0 +1,148 @@ +import type { + OnAssetsConversionHandler, + OnAssetsLookupHandler, + OnCronjobHandler, + OnKeyringRequestHandler, + OnUserInputHandler, + OnAssetHistoricalPriceHandler, + OnAssetsMarketDataHandler, + OnClientRequestHandler, + OnActiveHandler, +} from '@metamask/snaps-sdk'; + +import { Config } from './config'; +import { + KeyringHandler, + CronHandler, + UserInputHandler, + RpcHandler, + AssetsHandler, +} from './handlers'; +import { HandlerMiddleware } from './handlers/HandlerMiddleware'; +import { KeyringRequestHandler } from './handlers/KeyringRequestHandler'; +import { + SnapClientAdapter, + EsploraClientAdapter, + PriceApiClientAdapter, + ConsoleLoggerAdapter, + LocalTranslatorAdapter, +} from './infra'; +import { BdkAccountRepository, JSXSendFlowRepository } from './store'; +import { InMemoryCache } from './store/InMemoryCache'; +import { JSXConfirmationRepository } from './store/JSXConfirmationRepository'; +import { + AccountUseCases, + AssetsUseCases, + ConfirmationUseCases, + SendFlowUseCases, +} from './use-cases'; + +// Infra layer +const logger = new ConsoleLoggerAdapter(Config.logLevel); +const snapClient = new SnapClientAdapter(logger, Config.encrypt); +const chainClient = new EsploraClientAdapter(Config.chain); +const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); +const translator = new LocalTranslatorAdapter(); +const middleware = new HandlerMiddleware(logger, snapClient, translator); + +// Data layer +const accountRepository = new BdkAccountRepository(snapClient); +const sendFlowRepository = new JSXSendFlowRepository(snapClient, translator); +const confirmationRepository = new JSXConfirmationRepository( + snapClient, + translator, + chainClient, + assetRatesClient, + logger, +); + +// Business layer +const accountsUseCases = new AccountUseCases( + logger, + snapClient, + accountRepository, + confirmationRepository, + chainClient, + Config.fallbackFeeRate, + Config.targetBlocksConfirmation, +); +const sendFlowUseCases = new SendFlowUseCases( + logger, + snapClient, + accountRepository, + accountsUseCases, + sendFlowRepository, + chainClient, + assetRatesClient, + Config.targetBlocksConfirmation, + Config.fallbackFeeRate, + Config.ratesRefreshInterval, +); +const assetsUseCases = new AssetsUseCases( + logger, + assetRatesClient, + new InMemoryCache(), + snapClient, +); +const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); + +// Application layer +const keyringRequestHandler = new KeyringRequestHandler( + accountsUseCases, + confirmationRepository, +); +const keyringHandler = new KeyringHandler( + keyringRequestHandler, + accountsUseCases, + Config.defaultAddressType, + snapClient, + logger, +); +const cronHandler = new CronHandler( + accountsUseCases, + sendFlowUseCases, + snapClient, + snap, +); +const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases, logger); +const userInputHandler = new UserInputHandler( + sendFlowUseCases, + confirmationUseCases, +); +const assetsHandler = new AssetsHandler( + assetsUseCases, + Config.conversionsExpirationInterval, + logger, + snapClient, +); + +export const onCronjob: OnCronjobHandler = async ({ request }) => + middleware.handle(async () => cronHandler.route(request)); + +export const onClientRequest: OnClientRequestHandler = async ({ request }) => + middleware.handle(async () => rpcHandler.route('metamask', request)); + +export const onKeyringRequest: OnKeyringRequestHandler = async ({ request }) => + middleware.handle(async () => keyringHandler.route(request)); + +export const onUserInput: OnUserInputHandler = async ({ id, event, context }) => + middleware.handle(async () => userInputHandler.route(id, event, context)); + +export const onAssetsLookup: OnAssetsLookupHandler = async () => + middleware.handle(async () => assetsHandler.lookup()); + +export const onAssetsConversion: OnAssetsConversionHandler = async ({ + conversions, +}) => middleware.handle(async () => assetsHandler.conversion(conversions)); + +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ + from, + to, +}) => middleware.handle(async () => assetsHandler.historicalPrice(from, to)); + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async ({ + assets, +}) => middleware.handle(async () => assetsHandler.marketData(assets)); + +export const onActive: OnActiveHandler = async () => + middleware.handle(async () => cronHandler.synchronizeAccounts()); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts new file mode 100644 index 00000000..42015012 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { + ChangeSet, + DescriptorPair, + Network, +} from '@metamask/bitcoindevkit'; +import { Wallet } from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import { WalletError } from '../entities'; +import { BdkAccountAdapter } from './BdkAccountAdapter'; + +jest.mock('@metamask/bitcoindevkit', () => ({ + FeeRate: jest.fn(), + OutPoint: { from_string: jest.fn() }, + SignOptions: jest.fn(), + Txid: { from_string: jest.fn() }, + UnconfirmedTx: jest.fn(), + Wallet: { + create: jest.fn(), + load: jest.fn(), + }, +})); + +describe('BdkAccountAdapter', () => { + const mockWallet = mock(); + const mockId = 'test-id'; + const mockDerivationPath = ['m', "84'", "0'", "0'"]; + const mockDescriptors = mock({ + external: 'ext-desc', + internal: 'int-desc', + }); + const mockChangeSet = mock(); + const mockNetwork: Network = 'bitcoin'; + + beforeEach(() => { + jest.mocked(Wallet.create).mockReturnValue(mockWallet); + jest.mocked(Wallet.load).mockReturnValue(mockWallet); + }); + + describe('WebAssembly availability guard', () => { + // eslint-disable-next-line no-restricted-globals -- needed to save/restore the WebAssembly global in tests + let originalWebAssembly: typeof WebAssembly; + + beforeEach(() => { + originalWebAssembly = globalThis.WebAssembly; + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'WebAssembly', { + configurable: true, + value: originalWebAssembly, + writable: true, + }); + }); + + /** Simulates an environment where WebAssembly is unavailable (e.g., iOS Lockdown Mode). */ + function disableWebAssembly(): void { + Object.defineProperty(globalThis, 'WebAssembly', { + configurable: true, + value: undefined, + writable: true, + }); + } + + describe('create', () => { + it('throws WalletError when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(WalletError); + }); + + it('error message mentions iOS Lockdown Mode', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(/Lockdown Mode/u); + }); + + it('does not call Wallet.create when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(WalletError); + expect(Wallet.create).not.toHaveBeenCalled(); + }); + + it('calls Wallet.create when WebAssembly is available', () => { + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ); + + expect(Wallet.create).toHaveBeenCalledWith( + mockNetwork, + mockDescriptors.external, + mockDescriptors.internal, + ); + }); + + it('returns a BdkAccountAdapter instance when WebAssembly is available', () => { + const result = BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ); + + expect(result).toBeInstanceOf(BdkAccountAdapter); + }); + }); + + describe('load', () => { + it('throws WalletError when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(WalletError); + }); + + it('error message mentions iOS Lockdown Mode', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(/Lockdown Mode/u); + }); + + it('does not call Wallet.load when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(WalletError); + expect(Wallet.load).not.toHaveBeenCalled(); + }); + + it('calls Wallet.load without descriptors', () => { + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet); + + expect(Wallet.load).toHaveBeenCalledWith(mockChangeSet); + }); + + it('calls Wallet.load with descriptors', () => { + BdkAccountAdapter.load( + mockId, + mockDerivationPath, + mockChangeSet, + mockDescriptors, + ); + + expect(Wallet.load).toHaveBeenCalledWith( + mockChangeSet, + mockDescriptors.external, + mockDescriptors.internal, + ); + }); + + it('returns a BdkAccountAdapter instance when WebAssembly is available', () => { + const result = BdkAccountAdapter.load( + mockId, + mockDerivationPath, + mockChangeSet, + ); + + expect(result).toBeInstanceOf(BdkAccountAdapter); + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts new file mode 100644 index 00000000..760f6be7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -0,0 +1,283 @@ +import type { + AddressInfo, + AddressType, + Balance, + DescriptorPair, + FullScanRequest, + Network, + SyncRequest, + Update, + ChangeSet, + Psbt, + Transaction, + LocalOutput, + WalletTx, + Amount, + ScriptBuf, + Address, +} from '@metamask/bitcoindevkit'; +import { + FeeRate, + UnconfirmedTx, + SignOptions, + Txid, + Wallet, + OutPoint, +} from '@metamask/bitcoindevkit'; + +import { + AccountCapability, + ValidationError, + WalletError, + type BitcoinAccount, + type TransactionBuilder, +} from '../entities'; +import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; + +/** + * Throws a {@link WalletError} if WebAssembly is not available in the current environment. + */ +function assertWebAssemblyAvailable(): void { + // eslint-disable-next-line no-restricted-globals -- WebAssembly is a valid endowment in the snap execution environment (endowment:webassembly) + if (typeof WebAssembly === 'undefined') { + throw new WalletError( + 'WebAssembly is not available in this environment. ' + + 'If iOS Lockdown Mode is enabled, Bitcoin wallet functionality is not supported.', + ); + } +} + +export class BdkAccountAdapter implements BitcoinAccount { + readonly #id: string; + + readonly #derivationPath: string[]; + + readonly #wallet: Wallet; + + #staged?: ChangeSet; + + readonly #capabilities: AccountCapability[]; + + constructor(id: string, derivationPath: string[], wallet: Wallet) { + this.#id = id; + this.#derivationPath = derivationPath; + this.#wallet = wallet; + this.#capabilities = Object.values(AccountCapability); + } + + static create( + id: string, + derivationPath: string[], + descriptors: DescriptorPair, + network: Network, + ): BdkAccountAdapter { + assertWebAssemblyAvailable(); + return new BdkAccountAdapter( + id, + derivationPath, + Wallet.create(network, descriptors.external, descriptors.internal), + ); + } + + static load( + id: string, + derivationPath: string[], + walletData: ChangeSet, + descriptors?: DescriptorPair, + ): BdkAccountAdapter { + assertWebAssemblyAvailable(); + // Load with signer + if (descriptors) { + return new BdkAccountAdapter( + id, + derivationPath, + Wallet.load(walletData, descriptors.external, descriptors.internal), + ); + } + + return new BdkAccountAdapter(id, derivationPath, Wallet.load(walletData)); + } + + get id(): string { + return this.#id; + } + + get derivationPath(): string[] { + return this.#derivationPath; + } + + get entropySource(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#derivationPath[0]!; // Must be defined by assertion + } + + get accountIndex(): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const segment = this.#derivationPath[3]!; + const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment; + return Number(numericPart); + } + + get balance(): Balance { + return this.#wallet.balance; + } + + get addressType(): AddressType { + const addressType = this.peekAddress(0).address_type; + if (!addressType) { + throw new WalletError( + 'unknown, non-standard or related to the future witness version.', + ); + } + + return addressType; + } + + get network(): Network { + return this.#wallet.network; + } + + get publicAddress(): Address { + return this.peekAddress(0).address; + } + + get publicDescriptor(): string { + return this.#wallet.public_descriptor('external'); + } + + get capabilities(): AccountCapability[] { + return this.#capabilities; + } + + peekAddress(index: number): AddressInfo { + return this.#wallet.peek_address('external', index); + } + + nextUnusedAddress(): AddressInfo { + return this.#wallet.next_unused_address('external'); + } + + revealNextAddress(): AddressInfo { + return this.#wallet.reveal_next_address('external'); + } + + startFullScan(): FullScanRequest { + return this.#wallet.start_full_scan(); + } + + startSync(): SyncRequest { + return this.#wallet.start_sync_with_revealed_spks(); + } + + applyUpdate(update: Update): void { + return this.#wallet.apply_update(update); + } + + takeStaged(): ChangeSet | undefined { + const staged = this.#collectStaged(); + this.#staged = undefined; + return staged; + } + + hasStaged(): boolean { + return Boolean(this.#collectStaged()); + } + + buildTx(): TransactionBuilder { + return new BdkTxBuilderAdapter(this.#wallet.build_tx(), this.network); + } + + sign(psbt: Psbt): Psbt { + try { + const finalized = this.#wallet.sign(psbt, new SignOptions()); + // Signing a PSBT does not mean that the tx/psbt is final, like in a multi-sig setup. + // This ensures we only support signing where the psbt is final after. + if (!finalized) { + throw new WalletError('PSBT not finalized', { + psbt: psbt.toString(), + id: this.#id, + }); + } + + return psbt; + } catch (error) { + throw new WalletError( + 'failed to sign PSBT', + { + psbt: psbt.toString(), + id: this.#id, + }, + error, + ); + } + } + + extractTransaction(psbt: Psbt, maxFeeRate?: number): Transaction { + try { + return maxFeeRate + ? psbt.extract_tx_with_fee_rate_limit( + new FeeRate(BigInt(Math.floor(maxFeeRate))), + ) + : psbt.extract_tx(); + } catch (error) { + throw new ValidationError( + 'failed to extract transaction from PSBT', + { id: this.#id }, + error, + ); + } + } + + getUtxo(outpoint: string): LocalOutput | undefined { + try { + return this.#wallet.get_utxo(OutPoint.from_string(outpoint)); + } catch (error) { + throw new ValidationError('Invalid outpoint', { id: this.#id }, error); + } + } + + listUnspent(): LocalOutput[] { + return this.#wallet.list_unspent(); + } + + listTransactions(): WalletTx[] { + return this.#wallet.transactions(); + } + + getTransaction(txid: string): WalletTx | undefined { + return this.#wallet.get_tx(Txid.from_string(txid)); + } + + calculateFee(tx: Transaction): Amount { + return this.#wallet.calculate_fee(tx.clone()); + } + + isMine(script: ScriptBuf): boolean { + return this.#wallet.is_mine(script); + } + + sentAndReceived(tx: Transaction): [Amount, Amount] { + const sentAndReceived = this.#wallet.sent_and_received(tx.clone()); + return [sentAndReceived[0], sentAndReceived[1]]; + } + + applyUnconfirmedTx(tx: Transaction, lastSeen: number): void { + this.#wallet.apply_unconfirmed_txs([ + new UnconfirmedTx(tx, BigInt(lastSeen)), + ]); + } + + #collectStaged(): ChangeSet | undefined { + const staged = this.#wallet.take_staged(); + if (!staged) { + return this.#staged; + } + + if (this.#staged) { + staged.merge(this.#staged); + } + + this.#staged = staged; + return this.#staged; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts new file mode 100644 index 00000000..f03d46e3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -0,0 +1,108 @@ +import type { + Network, + Psbt, + ScriptBuf, + TxBuilder, +} from '@metamask/bitcoindevkit'; +import { + TxOrdering, + OutPoint, + Address, + Amount, + FeeRate, + Recipient, +} from '@metamask/bitcoindevkit'; + +import { + type CodifiedError, + ValidationError, + type TransactionBuilder, +} from '../entities'; + +export class BdkTxBuilderAdapter implements TransactionBuilder { + #builder: TxBuilder; + + readonly #network: Network; + + constructor(builder: TxBuilder, network: Network) { + this.#builder = builder; + this.#network = network; + } + + addRecipient(amount: string, recipientAddress: string): TransactionBuilder { + let recipient: Recipient; + try { + recipient = new Recipient( + Address.from_string(recipientAddress, this.#network).script_pubkey, + Amount.from_sat(BigInt(amount)), + ); + return this.addRecipientByScript( + recipient.amount, + recipient.script_pubkey, + ); + } catch (error) { + throw new ValidationError( + 'Invalid recipient', + { + amount, + address: recipientAddress, + }, + // Because of an issue with BDK, the error returned is not an Error object + // so we need to wrap it in an Error object + new Error((error as CodifiedError).message), + ); + } + } + + addRecipientByScript( + amount: Amount, + recipientScriptPubkey: ScriptBuf, + ): TransactionBuilder { + const recipient = new Recipient(recipientScriptPubkey, amount); + this.#builder = this.#builder.add_recipient(recipient); + return this; + } + + feeRate(feeRate: number): BdkTxBuilderAdapter { + this.#builder = this.#builder.fee_rate( + new FeeRate(BigInt(Math.floor(feeRate))), + ); + return this; + } + + drainWallet(): BdkTxBuilderAdapter { + this.#builder = this.#builder.drain_wallet(); + return this; + } + + drainTo(address: string): BdkTxBuilderAdapter { + const to = Address.from_string(address, this.#network); + return this.drainToByScript(to.script_pubkey); + } + + drainToByScript(scriptPubKey: ScriptBuf): BdkTxBuilderAdapter { + this.#builder = this.#builder.drain_to(scriptPubKey); + return this; + } + + unspendable(unspendable: string[]): BdkTxBuilderAdapter { + // Avoid calling WASM if there are no unspendable UTXOs + if (unspendable.length) { + const outpoints = unspendable.map((outpoint) => + OutPoint.from_string(outpoint), + ); + this.#builder = this.#builder.unspendable(outpoints); + } + + return this; + } + + untouchedOrdering(): BdkTxBuilderAdapter { + this.#builder = this.#builder.ordering(TxOrdering.Untouched); + return this; + } + + finish(): Psbt { + return this.#builder.finish(); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts new file mode 100644 index 00000000..de263406 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts @@ -0,0 +1,63 @@ +import type { Logger } from '../entities'; +import { LogLevel } from '../entities'; + +const logLevelPriority = { + [LogLevel.SILENT]: 0, + [LogLevel.ERROR]: 1, + [LogLevel.WARN]: 2, + [LogLevel.INFO]: 3, + [LogLevel.DEBUG]: 4, + [LogLevel.TRACE]: 5, +}; + +export class ConsoleLoggerAdapter implements Logger { + readonly #logLevel: LogLevel; + + constructor(logLevel: LogLevel) { + this.#logLevel = logLevel; + } + + #shouldLog(level: LogLevel): boolean { + return logLevelPriority[level] <= logLevelPriority[this.#logLevel]; + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error(...data: any[]): void { + if (this.#shouldLog(LogLevel.ERROR)) { + console.error(...data); + } + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn(...data: any[]): void { + if (this.#shouldLog(LogLevel.WARN)) { + console.warn(...data); + } + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info(...data: any[]): void { + if (this.#shouldLog(LogLevel.INFO)) { + console.info(...data); + } + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug(...data: any[]): void { + if (this.#shouldLog(LogLevel.DEBUG)) { + console.debug(...data); + } + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + trace(...data: any[]): void { + if (this.#shouldLog(LogLevel.TRACE)) { + console.trace(...data); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts new file mode 100644 index 00000000..2207af2b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -0,0 +1,95 @@ +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; +import { EsploraClient } from '@metamask/bitcoindevkit'; + +import { + type BitcoinAccount, + type ChainConfig, + type BlockchainClient, + ExternalServiceError, +} from '../entities'; + +export class EsploraClientAdapter implements BlockchainClient { + // Should be a Repository but we don't support custom networks so we can save in memory from config values + readonly #clients: Record; + + readonly #config: ChainConfig; + + constructor(config: ChainConfig) { + this.#clients = { + bitcoin: new EsploraClient(config.url.bitcoin, config.maxRetries), + testnet: new EsploraClient(config.url.testnet, config.maxRetries), + testnet4: new EsploraClient(config.url.testnet4, config.maxRetries), + signet: new EsploraClient(config.url.signet, config.maxRetries), + regtest: new EsploraClient(config.url.regtest, config.maxRetries), + }; + + this.#config = config; + } + + async fullScan(account: BitcoinAccount): Promise { + try { + const request = account.startFullScan(); + const update = await this.#clients[account.network].full_scan( + request, + this.#config.stopGap, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } catch (error) { + throw new ExternalServiceError( + `Failed to perform initial full scan`, + { account: account.id }, + error, + ); + } + } + + async sync(account: BitcoinAccount): Promise { + try { + const request = account.startSync(); + const update = await this.#clients[account.network].sync( + request, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } catch (error) { + throw new ExternalServiceError( + `Failed to synchronize account`, + { account: account.id }, + error, + ); + } + } + + async broadcast(network: Network, transaction: Transaction): Promise { + try { + await this.#clients[network].broadcast(transaction); + } catch (error) { + throw new ExternalServiceError( + `Failed to broadcast transaction`, + { network, txid: transaction.compute_txid().toString() }, + error, + ); + } + } + + async getFeeEstimates(network: Network): Promise { + try { + return await this.#clients[network].get_fee_estimates(); + } catch (error) { + throw new ExternalServiceError( + `Failed to fetch fee estimates`, + { network }, + error, + ); + } + } + + getExplorerUrl(network: Network): string { + return this.#config.explorerUrl[network]; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts new file mode 100644 index 00000000..c8284d22 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts @@ -0,0 +1,27 @@ +import type { Messages, Translator } from '../entities'; + +export class LocalTranslatorAdapter implements Translator { + #messages: Messages; + + #locale: string; + + constructor() { + this.#locale = ''; + this.#messages = {}; + } + + async load(locale: string): Promise { + if (this.#locale === locale) { + return this.#messages; + } + + try { + this.#messages = (await import(`../../locales/${locale}.json`)).messages; + } catch { + this.#messages = (await import(`../../locales/en.json`)).messages; + } + + this.#locale = locale; + return this.#messages; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts new file mode 100644 index 00000000..c080d193 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -0,0 +1,129 @@ +import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; + +import { + ExternalServiceError, + type AssetRatesClient, + type PriceApiConfig, + type SpotPrice, + type TimePeriod, +} from '../entities'; + +type SpotPricesResponse = { + price: number; + marketCap: number; + allTimeHigh: number; + allTimeLow: number; + totalVolume: number; + circulatingSupply: number; + pricePercentChange1h: number; + pricePercentChange1d: number; + pricePercentChange7d: number; + pricePercentChange14d: number; + pricePercentChange30d: number; + pricePercentChange200d: number; + pricePercentChange1y: number; +}; + +type HistoricalPricesResponse = { + prices: [number, number | null][]; +}; + +export class PriceApiClientAdapter implements AssetRatesClient { + readonly #endpoint: string; + + constructor(config: PriceApiConfig) { + this.#endpoint = config.url; + } + + async spotPrices( + vsCurrency = 'usd', + baseCurrency = 'bitcoin', + ): Promise { + const url = `${ + this.#endpoint + }/v1/spot-prices/${baseCurrency}?vsCurrency=${vsCurrency}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new ExternalServiceError(`Failed to fetch spot prices`, { + vsCurrency, + baseCurrency, + response: response.statusText, + }); + } + + const prices: SpotPricesResponse = await response.json(); + return { + price: prices.price, + marketData: { + fungible: true, + allTimeHigh: prices.allTimeHigh.toString(), + allTimeLow: prices.allTimeLow.toString(), + circulatingSupply: prices.circulatingSupply.toString(), + marketCap: prices.marketCap.toString(), + totalVolume: prices.totalVolume.toString(), + pricePercentChange: { + PT1H: prices.pricePercentChange1h, + P1D: prices.pricePercentChange1d, + P7D: prices.pricePercentChange7d, + P14D: prices.pricePercentChange14d, + P30D: prices.pricePercentChange30d, + P200D: prices.pricePercentChange200d, + P1Y: prices.pricePercentChange1y, + }, + }, + }; + } catch (error) { + throw new ExternalServiceError( + `Network failure while fetching spot prices`, + { + vsCurrency, + baseCurrency, + }, + error, + ); + } + } + + async historicalPrices( + timePeriod: TimePeriod, + vsCurrency = 'usd', + baseCurrency = 'bitcoin', + ): Promise { + const url = `${ + this.#endpoint + }/v1/historical-prices/${baseCurrency}?timePeriod=${timePeriod.slice( + 1, + )}&vsCurrency=${vsCurrency}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new ExternalServiceError(`Failed to fetch historical rates`, { + timePeriod, + vsCurrency, + baseCurrency, + response: response.statusText, + }); + } + + const prices: HistoricalPricesResponse = await response.json(); + return prices.prices + .filter(([, price]) => price !== null) // keep only non-null prices + .map(([ts, price]) => [ts, (price as number).toString()]); + } catch (error) { + throw new ExternalServiceError( + `Network failure while fetching historical prices`, + { + timePeriod, + vsCurrency, + baseCurrency, + }, + error, + ); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts new file mode 100644 index 00000000..64902bf9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts @@ -0,0 +1,158 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; +import { getJsonError } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount, Logger } from '../entities'; +import { TrackingSnapEvent } from '../entities'; +import { SnapClientAdapter } from './SnapClientAdapter'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Amount: { + from_sat: jest.fn(() => ({ + to_btc: jest.fn(() => ({ + toString: jest.fn(() => '0'), + })), + })), + }, +})); +/* eslint-enable @typescript-eslint/naming-convention */ + +const setupTest = () => { + const mockLogger = mock(); + const mockRequest = jest.fn(); + const snapClient = new SnapClientAdapter(mockLogger); + + Object.defineProperty(globalThis, 'snap', { + configurable: true, + value: { request: mockRequest }, + writable: true, + }); + + return { snapClient, mockLogger, mockRequest }; +}; + +describe('SnapClientAdapter', () => { + describe('emitTrackingEvent', () => { + it("doesn't throw and logs when event tracking fails", async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const trackingError = new Error('event tracking failed'); + const account = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + }); + const tx = mock({ + txid: { toString: () => 'txid-123' }, + }); + mockRequest.mockRejectedValue(trackingError); + + expect( + await snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + 'metamask', + ), + ).toBeUndefined(); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: TrackingSnapEvent.TransactionReceived, + properties: { + origin: 'metamask', + message: 'Snap transaction received', + chain_id_caip: 'bip122:000000000019d6689c085ae165831e93', + account_type: 'bip122:p2wpkh', + tx_id: 'txid-123', + }, + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event: Transaction Received', + trackingError, + ); + }); + }); + + describe('emitTrackingError', () => { + it('sends the tracking error payload to the snap client', async () => { + const { snapClient, mockRequest } = setupTest(); + + const error = new Error('boom'); + mockRequest.mockResolvedValue(undefined); + + expect(await snapClient.emitTrackingError(error)).toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { error: getJsonError(error) }, + }); + }); + + it("doesn't break execution when error tracking fails", async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const error = new Error('boom'); + const trackingError = new Error('track failed'); + mockRequest.mockRejectedValue(trackingError); + + expect(await snapClient.emitTrackingError(error)).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track error', + trackingError, + ); + }); + }); + + describe('startTrace', () => { + it('returns false and logs when starting a trace fails', async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const traceError = new Error('trace failed'); + mockRequest.mockRejectedValue(traceError); + + expect(await snapClient.startTrace('Create Bitcoin Account')).toBe(false); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_startTrace', + params: { + name: 'Create Bitcoin Account', + }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to start trace', + traceError, + ); + }); + }); + + describe('endTrace', () => { + it('does not throw and logs when ending a trace fails', async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const traceError = new Error('trace end failed'); + mockRequest.mockRejectedValue(traceError); + + expect( + await snapClient.endTrace('Create Bitcoin Account'), + ).toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_endTrace', + params: { + name: 'Create Bitcoin Account', + }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to end trace', + traceError, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts new file mode 100644 index 00000000..8d8a6d39 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -0,0 +1,348 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; +import { Amount } from '@metamask/bitcoindevkit'; +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { + GetClientStatusResult, + ComponentOrElement, + GetInterfaceContextResult, + GetInterfaceStateResult, + GetPreferencesResult, + Json, +} from '@metamask/snaps-sdk'; +import { DialogType, getJsonError } from '@metamask/snaps-sdk'; + +import type { BitcoinAccount, Logger, SnapClient } from '../entities'; +import { + computeDisplayBalanceSats, + TrackingSnapEvent, + networkToCurrencyUnit, + AssertionError, +} from '../entities'; +import { + addressTypeToCaip, + networkToCaip19, + networkToScope, +} from '../handlers'; +import { mapToKeyringAccount, mapToTransaction } from '../handlers/mappings'; + +export class SnapClientAdapter implements SnapClient { + readonly #encrypt: boolean; + + readonly #logger: Logger; + + constructor(logger: Logger, encrypt = false) { + this.#logger = logger; + this.#encrypt = encrypt; + } + + decideToEncrypt(key?: string): boolean { + if (!key) { + return this.#encrypt; + } + + return key.includes('accounts') || this.#encrypt; + } + + async getState(key?: string): Promise { + return snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.decideToEncrypt(key), + }, + }); + } + + async setState(key?: string, newState: Json = {}): Promise { + await snap.request({ + method: 'snap_setState', + params: { + key, + value: newState, + encrypted: this.decideToEncrypt(key), + }, + }); + } + + async getPrivateEntropy(derivationPath: string[]): Promise { + const source = derivationPath[0] === 'm' ? undefined : derivationPath[0]; + const path = ['m', ...derivationPath.slice(1)]; + + return snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve: 'secp256k1', + source, + }, + }); + } + + async getPublicEntropy(derivationPath: string[]): Promise { + const slip10 = await this.getPrivateEntropy(derivationPath); + return (await SLIP10Node.fromJSON(slip10)).neuter(); + } + + async emitAccountCreatedEvent( + account: BitcoinAccount, + correlationId?: string, + accountName?: string, + ): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { + account: mapToKeyringAccount(account), + accountNameSuggestion: accountName, + displayConfirmation: false, + displayAccountNameSuggestion: false, + ...(correlationId ? { metamask: { correlationId } } : {}), + }); + } + + async emitAccountDeletedEvent(id: string): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountDeleted, { + id, + }); + } + + async emitAccountBalancesUpdatedEvent( + accounts: BitcoinAccount[], + ): Promise { + const balances = accounts.reduce< + Record> + >( + (acc, account) => ({ + ...acc, + [account.id]: { + [networkToCaip19[account.network]]: { + amount: Amount.from_sat(computeDisplayBalanceSats(account)) + .to_btc() + .toString(), + unit: networkToCurrencyUnit[account.network], + }, + }, + }), + {}, + ); + + return emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances, + }); + } + + async emitAccountTransactionsUpdatedEvent( + account: BitcoinAccount, + txs: WalletTx[], + ): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: { + [account.id]: txs.map((tx) => mapToTransaction(account, tx)), + }, + }); + } + + async createInterface( + ui: ComponentOrElement, + context: Record, + ): Promise { + return snap.request({ + method: 'snap_createInterface', + params: { ui, context }, + }); + } + + async updateInterface( + id: string, + ui: ComponentOrElement, + context: Record, + ): Promise { + await snap.request({ + method: 'snap_updateInterface', + params: { id, ui, context }, + }); + } + + async displayInterface(id: string): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { id }, + })) as unknown as ResolveType; + } + + async displayConfirmation( + id: string, + ): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { type: DialogType.Confirmation, id }, + })) as unknown as ResolveType; + } + + async displayUserPrompt( + id: string, + ): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { id }, + })) as unknown as ResolveType; + } + + async getInterfaceState(id: string): Promise { + return snap.request({ + method: 'snap_getInterfaceState', + params: { id }, + }); + } + + async getInterfaceContext(id: string): Promise { + return snap.request({ + method: 'snap_getInterfaceContext', + params: { id }, + }); + } + + async resolveInterface(id: string, value: Json): Promise { + await snap.request({ + method: 'snap_resolveInterface', + params: { id, value }, + }); + } + + /** + * 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. + */ + async scheduleBackgroundEvent({ + method, + params = {}, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise { + return snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); + } + + async cancelBackgroundEvent(id: string): Promise { + await snap.request({ + method: 'snap_cancelBackgroundEvent', + params: { + id, + }, + }); + } + + async getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }); + } + + async getClientStatus(): Promise { + return snap.request({ + method: 'snap_getClientStatus', + }); + } + + async emitTrackingEvent( + eventType: TrackingSnapEvent, + account: BitcoinAccount, + tx: WalletTx, + origin: string, + ): Promise { + try { + const createMessage = (): string => { + switch (eventType) { + case TrackingSnapEvent.TransactionFinalized: + return 'Snap transaction finalized'; + case TrackingSnapEvent.TransactionSubmitted: + return 'Snap transaction submitted'; + case TrackingSnapEvent.TransactionReorged: + return 'Snap transaction reorged'; + case TrackingSnapEvent.TransactionReceived: + return 'Snap transaction received'; + default: + throw new AssertionError(`Unhandled tracking event type`, { + eventType, + origin, + }); + } + }; + + /* eslint-disable @typescript-eslint/naming-convention */ + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event: eventType, + properties: { + origin, + message: createMessage(), + chain_id_caip: networkToScope[account.network], + account_type: addressTypeToCaip[account.addressType], + tx_id: tx.txid.toString(), + }, + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + } catch (error) { + this.#logger.error(`Failed to track event: ${eventType}`, error); + } + } + + async emitTrackingError(error: Error): Promise { + try { + await snap.request({ + method: 'snap_trackError', + params: { error: getJsonError(error) }, + }); + } catch (trackingError) { + this.#logger.error('Failed to track error', trackingError); + } + } + + async startTrace(name: string): Promise { + try { + await snap.request({ + method: 'snap_startTrace', + params: { + name, + }, + }); + return true; + } catch (error) { + this.#logger.error(`Failed to start trace`, error); + return false; + } + } + + async endTrace(name: string): Promise { + try { + await snap.request({ + method: 'snap_endTrace', + params: { + name, + }, + }); + } catch (error) { + this.#logger.error(`Failed to end trace`, error); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts new file mode 100644 index 00000000..ae7c9d57 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts @@ -0,0 +1,185 @@ +import type { + AddressInfo, + Amount, + Balance, + FullScanRequest, + LocalOutput, + Psbt, + ScriptBuf, + SyncRequest, + Transaction, + Update, + WalletTx, + ChangeSet, + AddressType, + Network, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; + +import { + AccountCapability, + WalletError, + type AccountMetadata, + type AccountState, + type BitcoinAccount, + type TransactionBuilder, +} from '../entities'; + +type AccountStateWithMetadata = AccountState & { + metadata: AccountMetadata; +}; + +export class StoredAccountAdapter implements BitcoinAccount { + readonly #id: string; + + readonly #derivationPath: string[]; + + readonly #metadata: AccountMetadata; + + readonly #capabilities: AccountCapability[]; + + constructor(id: string, account: AccountStateWithMetadata) { + this.#id = id; + this.#derivationPath = account.derivationPath; + this.#metadata = account.metadata; + this.#capabilities = Object.values(AccountCapability); + } + + static canLoad(account: AccountState): account is AccountStateWithMetadata { + return Boolean(account.metadata); + } + + static load( + id: string, + account: AccountStateWithMetadata, + ): StoredAccountAdapter { + return new StoredAccountAdapter(id, account); + } + + get id(): string { + return this.#id; + } + + get derivationPath(): string[] { + return this.#derivationPath; + } + + get entropySource(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#derivationPath[0]!; + } + + get accountIndex(): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const segment = this.#derivationPath[3]!; + const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment; + return Number(numericPart); + } + + get balance(): Balance { + return this.#unsupported(); + } + + get addressType(): AddressType { + return this.#metadata.addressType; + } + + get network(): Network { + return this.#metadata.network; + } + + get publicAddress(): Address { + return Address.from_string(this.#metadata.address, this.#metadata.network); + } + + get publicDescriptor(): string { + return this.#metadata.publicDescriptor; + } + + get capabilities(): AccountCapability[] { + return this.#capabilities; + } + + peekAddress(_index: number): AddressInfo { + return this.#unsupported(); + } + + nextUnusedAddress(): AddressInfo { + return this.#unsupported(); + } + + revealNextAddress(): AddressInfo { + return this.#unsupported(); + } + + startFullScan(): FullScanRequest { + return this.#unsupported(); + } + + startSync(): SyncRequest { + return this.#unsupported(); + } + + applyUpdate(_update: Update): void { + this.#unsupported(); + } + + takeStaged(): ChangeSet | undefined { + return this.#unsupported(); + } + + hasStaged(): boolean { + return false; + } + + buildTx(): TransactionBuilder { + return this.#unsupported(); + } + + sign(_psbt: Psbt): Psbt { + return this.#unsupported(); + } + + extractTransaction(_psbt: Psbt, _maxFeeRate?: number): Transaction { + return this.#unsupported(); + } + + getUtxo(_outpoint: string): LocalOutput | undefined { + return this.#unsupported(); + } + + listUnspent(): LocalOutput[] { + return this.#unsupported(); + } + + listTransactions(): WalletTx[] { + return this.#unsupported(); + } + + getTransaction(_txid: string): WalletTx | undefined { + return this.#unsupported(); + } + + calculateFee(_tx: Transaction): Amount { + return this.#unsupported(); + } + + isMine(_script: ScriptBuf): boolean { + return this.#unsupported(); + } + + sentAndReceived(_tx: Transaction): [Amount, Amount] { + return this.#unsupported(); + } + + applyUnconfirmedTx(_tx: Transaction, _lastSeen: number): void { + this.#unsupported(); + } + + #unsupported(): never { + throw new WalletError( + 'Stored account metadata cannot be used for wallet operations; load the full account first.', + { id: this.#id }, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts new file mode 100644 index 00000000..fa4ce4fd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -0,0 +1,7 @@ +export * from './BdkAccountAdapter'; +export * from './StoredAccountAdapter'; +export * from './SnapClientAdapter'; +export * from './EsploraClientAdapter'; +export * from './PriceApiClientAdapter'; +export * from './ConsoleLoggerAdapter'; +export * from './LocalTranslatorAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx new file mode 100644 index 00000000..d5ef52ab --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx @@ -0,0 +1,25 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Image } from '@metamask/snaps-sdk/jsx'; + +import btcIcon from '../images/bitcoin.svg'; +import signetIcon from '../images/signet.svg'; +import testnetIcon from '../images/testnet.svg'; + +const networkToIcon: Record = { + bitcoin: btcIcon, + testnet: testnetIcon, + testnet4: testnetIcon, + signet: signetIcon, + regtest: signetIcon, +}; + +export type AssetIconProps = { + network: Network; +}; + +export const AssetIcon: SnapComponent = ({ network }) => ( + + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx new file mode 100644 index 00000000..5a542278 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx @@ -0,0 +1,30 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Image } from '@metamask/snaps-sdk/jsx'; + +import btcIconInline from '../images/bitcoin-inline.svg'; +import signetIcon from '../images/signet.svg'; +import testnetIcon from '../images/testnet.svg'; + +const networkToIcon: Record = { + bitcoin: btcIconInline, + testnet: testnetIcon, + testnet4: testnetIcon, + signet: signetIcon, + regtest: signetIcon, +}; + +export type AssetIconInlineProps = { + network: Network; + variant?: 'asset' | 'network'; +}; + +export const AssetIconInline: SnapComponent = ({ + network, + variant = 'asset', +}) => ( + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx new file mode 100644 index 00000000..6f25b8f0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx @@ -0,0 +1,29 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Heading, Icon } from '@metamask/snaps-sdk/jsx'; + +export type HeadingWithReturnProps = { + heading: string; + returnButtonName: string; +}; + +export const HeadingWithReturn: SnapComponent = ({ + heading, + returnButtonName, +}) => ( + + + {heading} + {/* FIXME: This empty space is needed to center-align the header text. + * The Snap UI centers the text within its container, but the container + * itself is misaligned in the header due to the back arrow. + */} + + {null} + {null} + {null} + {null} + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx new file mode 100644 index 00000000..14c1383b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -0,0 +1,169 @@ +import type { JSXElement } from '@metamask/snaps-sdk/jsx'; +import { + AccountSelector, + AssetSelector, + Box, + Button, + Field, + Form, + Icon, + Input, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; + +import type { Messages, SendFormContext } from '../../../entities'; +import { CurrencyUnit, SendFormEvent } from '../../../entities'; +import { networkToCaip19, networkToScope } from '../../../handlers'; +import { + displayAmount, + displayExchangeAmount, + errorCodeToLabel, + exchangeAmount, + translate, +} from '../format'; + +type SendFormProps = SendFormContext & { + messages: Messages; +}; + +export const SendForm = (props: SendFormProps): JSXElement => { + const { + currency, + balance, + amount, + recipient, + network, + account, + errors, + messages, + drain, + exchangeRate, + } = props; + const t = translate(messages); + + const validAddress = Boolean(recipient && !errors.recipient); + const chainId = networkToScope[network]; + const caip10Address = `${chainId}:${account.address}` as CaipAccountId; + const isCurrencySwapped = currency === CurrencyUnit.Fiat; + const amountValue = (): string => { + if (!amount) { + return ''; + } + return isCurrencySwapped + ? exchangeAmount(BigInt(amount), exchangeRate) + : displayAmount(BigInt(amount)); + }; + + return ( +
+ + + + + {null} + {null} + {null} + + + + {Boolean(recipient) && ( + + + + )} + + + {validAddress && ( + + {null} + {null} + {null} + + + + + + + + + + + + {isCurrencySwapped && exchangeRate + ? exchangeRate.currency + : currency} + + + {exchangeRate && ( + + )} + + + + + + + {`${t('balance')}: ${isCurrencySwapped ? displayExchangeAmount(BigInt(balance), exchangeRate) : displayAmount(BigInt(balance), currency)}`} + + + {drain ? ( + + ) : ( + + )} + + + )} +
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts new file mode 100644 index 00000000..9d729bef --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts @@ -0,0 +1,4 @@ +export * from './SendForm'; +export * from './HeadingWithReturn'; +export * from './AssetIcon'; +export * from './AssetIconInline'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx new file mode 100644 index 00000000..2388c56a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx @@ -0,0 +1,86 @@ +import { + Address, + Box, + Button, + Container, + Footer, + Icon, + Section, + Text as SnapText, + Tooltip, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { + Messages, + SignMessageConfirmationContext, +} from '../../../entities'; +import { ConfirmationEvent } from '../../../entities'; +import { HeadingWithReturn } from '../components'; +import { displayCaip10, displayOrigin, translate } from '../format'; + +type SignMessageConfirmationViewProps = { + context: SignMessageConfirmationContext; + messages: Messages; +}; + +export const SignMessageConfirmationView: SnapComponent< + SignMessageConfirmationViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { account, network, origin, message } = context; + const originHostname = origin ? displayOrigin(origin) : null; + + return ( + + + + +
+ + + {t('confirmation.signMessage.message')} + + + + {message} + +
+ +
+ {originHostname ? ( + + + + {t('confirmation.origin')} + + + + + + {originHostname} + + ) : null} + + + {t('confirmation.account')} + +
+ +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx new file mode 100644 index 00000000..56f0937c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx @@ -0,0 +1,188 @@ +import { + Address, + Box, + Button, + Container, + Copyable, + Footer, + Section, + Text as SnapText, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, SignPsbtConfirmationContext } from '../../../entities'; +import { ConfirmationEvent } from '../../../entities'; +import { AssetIconInline, HeadingWithReturn } from '../components'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayNetwork, + translate, +} from '../format'; + +type SignPsbtConfirmationViewProps = { + context: SignPsbtConfirmationContext; + messages: Messages; +}; + +export const SignPsbtConfirmationView: SnapComponent< + SignPsbtConfirmationViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { + account, + network, + origin, + psbt, + options, + fee, + currency, + exchangeRate, + outputs, + inputCount, + } = context; + + return ( + + + + + {outputs.length > 0 ? ( +
+ + + {t('confirmation.signPsbt.outputs')} + + + {outputs.map((output, index) => { + let label: string; + if (output.isOpReturn) { + label = t('confirmation.signPsbt.output.opReturn'); + } else if (output.isMine) { + label = t('confirmation.signPsbt.output.change'); + } else if (output.address) { + label = `${t('toAddress')} #${index + 1}`; + } else { + label = t('confirmation.signPsbt.output.unknown'); + } + + return ( + + + {label} + + + {displayExchangeAmount( + BigInt(output.amount), + exchangeRate, + )} + + + {displayAmount(BigInt(output.amount), currency)} + + + + {output.address ? ( + +
+ + ) : null} + + ); + })} +
+ ) : null} + +
+ + + {t('confirmation.signPsbt.options')} + + + + + {t('confirmation.signPsbt.options.fill')} + + {options.fill ? t('yes') : t('no')} + + + + {t('confirmation.signPsbt.options.broadcast')} + + {options.broadcast ? t('yes') : t('no')} + + {inputCount > 0 ? ( + + + {t('confirmation.signPsbt.inputs')} + + {inputCount.toString()} + + ) : null} + {fee === undefined ? null : ( + + {t('networkFee')} + + + {displayExchangeAmount(BigInt(fee), exchangeRate)} + + {displayAmount(BigInt(fee), currency)} + + + )} +
+ +
+ + + {t('confirmation.requestOrigin')} + + {origin ?? 'MetaMask'} + + {null} + + + {t('from')} + +
+ + {null} + + + {t('network')} + + + + {displayNetwork(network)} + + +
+ +
+ + + {t('confirmation.signPsbt.rawPsbt')} + + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts new file mode 100644 index 00000000..9d8461da --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts @@ -0,0 +1,2 @@ +export * from './SignMessageConfirmationView'; +export * from './SignPsbtConfirmationView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts new file mode 100644 index 00000000..a175612f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts @@ -0,0 +1,50 @@ +import { displayOrigin } from './format'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Amount: { + from_sat: jest.fn(), + }, + BdkErrorCode: {}, +})); +/* eslint-enable @typescript-eslint/naming-convention */ + +describe('displayOrigin', () => { + it('returns the known label for the internal "metamask" origin', () => { + expect(displayOrigin('metamask')).toBe('MetaMask'); + }); + + it('returns the known label for the "wallet-connect" origin', () => { + expect(displayOrigin('wallet-connect')).toBe('WalletConnect'); + }); + + it('matches known origins case-insensitively', () => { + expect(displayOrigin('MetaMask')).toBe('MetaMask'); + expect(displayOrigin('Wallet-Connect')).toBe('WalletConnect'); + }); + + it('returns the hostname for a valid https URL', () => { + expect(displayOrigin('https://app.uniswap.org')).toBe('app.uniswap.org'); + }); + + it('returns the hostname for a valid http URL', () => { + expect(displayOrigin('http://localhost:8080')).toBe('localhost'); + }); + + it('returns an empty string for a non-http(s) URL', () => { + expect(displayOrigin('ftp://example.com')).toBe(''); + }); + + it('returns an empty string for a WalletConnect channelId', () => { + expect( + displayOrigin( + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', + ), + ).toBe(''); + }); + + it('returns an empty string for an invalid origin', () => { + expect(displayOrigin('not a url')).toBe(''); + expect(displayOrigin('')).toBe(''); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts new file mode 100644 index 00000000..8e54922f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -0,0 +1,106 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import { Amount, BdkErrorCode } from '@metamask/bitcoindevkit'; +import type { CaipAccountId } from '@metamask/snaps-sdk'; + +import type { CurrencyRate, CurrencyUnit, Messages } from '../../entities'; +import { networkToScope } from '../../handlers'; + +export const displayAmount = ( + amountSats: bigint, + currency?: CurrencyUnit, +): string => { + const amount = Amount.from_sat(amountSats).to_btc(); + if (currency) { + return `${amount} ${currency}`; + } + + return amount.toString(); +}; + +export const exchangeAmount = ( + amount: bigint, + exchangeRate?: CurrencyRate, +): string => { + if (!exchangeRate) { + return ''; + } + + return ((Number(amount) * exchangeRate.conversionRate) / 1e8).toFixed(2); +}; + +export const displayExchangeAmount = ( + amount: bigint, + exchangeRate?: CurrencyRate, +): string => { + return exchangeRate + ? `${exchangeAmount(amount, exchangeRate)} ${exchangeRate.currency}` + : ''; +}; + +export const translate = + (messages: Messages) => + (key: string): string => + messages[key]?.message ?? `{${key}}`; + +export const displayExplorerUrl = (url: string, address: string): string => + `${url}/address/${address}`; + +export const isValidSnapLinkProtocol = (url: string): boolean => { + try { + const { protocol } = new URL(url); + return ( + protocol === 'https:' || + protocol === 'mailto:' || + protocol === 'metamask:' + ); + } catch { + return false; + } +}; + +export const errorCodeToLabel = (code: number): string => { + const raw = BdkErrorCode[code] as string | undefined; + if (!raw) { + return 'unknownError'; + } + + // lowercase the first letter to respect camelCase convention + return raw.charAt(0).toLowerCase() + raw.slice(1); +}; + +/** + * Known origins mapped to their human-readable labels. Keys are lowercased so + * lookups can be performed case-insensitively against the raw origin. + */ +const KNOWN_ORIGIN_LABELS: Record = { + metamask: 'MetaMask', + 'wallet-connect': 'WalletConnect', +}; + +export const displayOrigin = (origin: string): string => { + const knownLabel = KNOWN_ORIGIN_LABELS[origin.toLowerCase()]; + if (knownLabel) { + return knownLabel; + } + + try { + const url = new URL(origin); + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.hostname + : ''; + } catch { + console.log('[format] - displayOrigin - failed to parse origin', origin); + return ''; + } +}; + +export const displayCaip10 = ( + network: Network, + address: string, +): CaipAccountId => { + return `${networkToScope[network]}:${address}`; +}; + +export const displayNetwork = (network: Network): string => { + return network.charAt(0).toUpperCase() + network.slice(1); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg new file mode 100644 index 00000000..aad90223 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg new file mode 100644 index 00000000..cbed14c8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg new file mode 100644 index 00000000..54544af3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg new file mode 100644 index 00000000..e422cc50 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts new file mode 100644 index 00000000..39c17769 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts @@ -0,0 +1,2 @@ +export * from './send-flow'; +export * from './confirmations'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx new file mode 100644 index 00000000..793d9fcb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx @@ -0,0 +1,131 @@ +import { Psbt } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Button, + Container, + Footer, + Heading, + Row, + Section, + Text as SnapText, + Value, + Address, + Link, +} from '@metamask/snaps-sdk/jsx'; + +import { Config } from '../../../config'; +import type { Messages, ReviewTransactionContext } from '../../../entities'; +import { + BlockTime, + networkToCurrencyUnit, + ReviewTransactionEvent, +} from '../../../entities'; +import { AssetIcon, HeadingWithReturn } from '../components'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayExplorerUrl, + isValidSnapLinkProtocol, + translate, +} from '../format'; + +type ReviewTransactionViewProps = { + context: ReviewTransactionContext; + messages: Messages; +}; + +export const ReviewTransactionView: SnapComponent< + ReviewTransactionViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { amount, exchangeRate, recipient, network, from, explorerUrl } = + context; + + const psbt = Psbt.from_string(context.psbt); + const fee = psbt.fee().to_sat(); + const feeRate = psbt.fee_rate()?.to_sat_per_vb_floor(); + const total = BigInt(amount) + fee; + const currency = networkToCurrencyUnit[network]; + + return ( + + + + + + + {displayAmount(BigInt(amount), currency)} + + {displayExchangeAmount(BigInt(amount), exchangeRate)} + + + +
+ + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + + + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + +
+ +
+ + + {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( + 'minutes', + )}`} + + + + + + + {`${feeRate} sat/vB`} + + + + +
+
+ +
+ +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx new file mode 100644 index 00000000..e709d208 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx @@ -0,0 +1,57 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Banner, + Box, + Button, + Container, + Footer, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, SendFormContext } from '../../../entities'; +import { SendFormEvent } from '../../../entities'; +import { HeadingWithReturn, SendForm } from '../components'; +import { errorCodeToLabel, translate } from '../format'; + +export type SendFormViewProps = { + context: SendFormContext; + messages: Messages; +}; + +export const SendFormView: SnapComponent = ({ + context, + messages, +}) => { + const t = translate(messages); + const { errors } = context; + + return ( + + + + + + + {errors.tx && ( + + {null} + + + {t(errorCodeToLabel(errors.tx.code))} + + + + )} + + +
+ +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts new file mode 100644 index 00000000..34d7abff --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts @@ -0,0 +1,2 @@ +export * from './SendFormView'; +export * from './ReviewTransactionView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx new file mode 100644 index 00000000..774cd145 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -0,0 +1,177 @@ +import { Psbt } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Address, + Banner, + Heading, + Link, + Section, + Box, + Button, + Container, + Footer, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, ConfirmSendFormContext } from '../../../entities'; +import { ConfirmationEvent, networkToCurrencyUnit } from '../../../entities'; +import { AssetIconInline } from '../components'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayExplorerUrl, + displayNetwork, + isValidSnapLinkProtocol, + translate, +} from '../format'; + +export type UnifiedSendFormViewProps = { + context: ConfirmSendFormContext; + messages: Messages; +}; + +export const UnifiedSendFormView: SnapComponent = ({ + context, + messages, +}) => { + const t = translate(messages); + const { + amount, + exchangeRate, + network, + from, + recipient, + explorerUrl, + origin, + isMine, + } = context; + + const psbt = Psbt.from_string(context.psbt); + const fee = psbt.fee().to_sat(); + const currency = networkToCurrencyUnit[network]; + + return ( + + + + {null} + + {t('confirmation.signAndSendTransaction.title')} + + {null} + + + {isMine ? ( + + + {t('confirmation.signAndSendTransaction.selfSend.description')} + + + ) : null} + +
+ + + {t('confirmation.estimatedChanges')} + + + + + {t('confirmation.estimatedChanges.send')} + + + + + -{displayAmount(BigInt(amount), currency).replace(' BTC', '')} + + + BTC + + + {displayExchangeAmount(BigInt(amount), exchangeRate)} + + + +
+ +
+ + + {t('confirmation.requestOrigin')} + + {origin ?? 'MetaMask'} + + {null} + + + {t('from')} + + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + + {null} + + + {t('toAddress')} + + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + + {null} + + + {t('network')} + + + + {displayNetwork(network)} + + + {null} + + + {t('networkFee')} + + + + {displayExchangeAmount(fee, exchangeRate)} + + {displayAmount(fee, currency)} + + +
+
+ +
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts new file mode 100644 index 00000000..679f4614 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts @@ -0,0 +1 @@ +export * from './UnifiedSendFormView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts new file mode 100644 index 00000000..c4a30e2b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -0,0 +1,686 @@ +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable camelcase */ + +import type { DescriptorPair } from '@metamask/bitcoindevkit'; +import { + Address, + ChangeSet, + xpriv_to_descriptor, + xpub_to_descriptor, +} from '@metamask/bitcoindevkit'; +import type { SLIP10Node } from '@metamask/key-tree'; +import { mock } from 'jest-mock-extended'; + +import type { + AccountState, + BitcoinAccount, + Inscription, + SnapClient, +} from '../entities'; +import { BdkAccountAdapter } from '../infra'; +import { BdkAccountRepository } from './BdkAccountRepository'; + +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => { + return { + ChangeSet: { + from_json: jest.fn(), + }, + Address: { + from_string: jest.fn(), + }, + slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), + xpub_to_descriptor: jest.fn(), + xpriv_to_descriptor: jest.fn(), + }; +}); + +jest.mock('../infra/BdkAccountAdapter', () => ({ + BdkAccountAdapter: { + load: jest.fn(), + create: jest.fn(), + }, +})); + +jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })); + +describe('BdkAccountRepository', () => { + const mockSnapClient = mock(); + const mockWalletData = '{"mywallet":"data"}'; + const mockDerivationPath = ['m', "84'", "0'", "0'"]; + const mockSlip10Node = { + masterFingerprint: 0xdeadbeef, + } as unknown as SLIP10Node; + const mockDescriptors = mock({ + external: 'ext-desc', + internal: 'int-desc', + }); + const mockAccountState = mock({ + wallet: mockWalletData, + derivationPath: mockDerivationPath, + }); + const mockChangeSet = mock(); + const mockAddress = mock
({ + toString: () => 'bc1qaddress...', + }); + const mockAccount = mock({ + id: 'some-id', + derivationPath: mockDerivationPath, + network: 'bitcoin', + addressType: 'p2wpkh', + publicAddress: mockAddress, + publicDescriptor: 'mock-public-descriptor', + }); + + const repo = new BdkAccountRepository(mockSnapClient); + + beforeEach(() => { + (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + (BdkAccountAdapter.create as jest.Mock).mockReturnValue(mockAccount); + (ChangeSet.from_json as jest.Mock).mockReturnValue(mockChangeSet); + mockSnapClient.getPrivateEntropy.mockResolvedValue(mockSlip10Node); + mockSnapClient.getPublicEntropy.mockResolvedValue(mockSlip10Node); + (xpriv_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + (xpub_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + jest.mocked(Address.from_string).mockReturnValue(mockAddress); + (mockAccount.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (mockAccount.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); + (mockChangeSet.to_json as jest.Mock) = jest + .fn() + .mockReturnValue(mockWalletData); + }); + + describe('get', () => { + it('returns null if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.get('non-existent-id'); + + expect(result).toBeNull(); + }); + + it('returns loaded account if found', async () => { + mockSnapClient.getState.mockResolvedValue(mockAccountState); + + const result = await repo.get('some-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts.some-id'); + expect(ChangeSet.from_json).toHaveBeenCalledWith(mockWalletData); + expect(BdkAccountAdapter.load).toHaveBeenCalledWith( + mockAccount.id, + mockDerivationPath, + mockChangeSet, + ); + expect(result).toBe(mockAccount); + }); + }); + + describe('getAll', () => { + it('returns empty array if no accounts found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getAll(); + + expect(result).toStrictEqual([]); + }); + + it('returns all accounts', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const state = { + [id1]: { ...mockAccountState, id: id1 }, + [id2]: { ...mockAccountState, id: id2 }, + }; + const mockAccount1 = { ...mockAccount, id: id1 }; + const mockAccount2 = { ...mockAccount, id: id2 }; + + mockSnapClient.getState.mockResolvedValue(state); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getAll(); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + }); + + it('filters out null accounts', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const id3 = 'deleted-id'; + const state = { + [id1]: { ...mockAccountState, id: id1 }, + [id2]: { ...mockAccountState, id: id2 }, + [id3]: null, // Deleted account + }; + const mockAccount1 = { ...mockAccount, id: id1 }; + const mockAccount2 = { ...mockAccount, id: id2 }; + + mockSnapClient.getState.mockResolvedValue(state); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getAll(); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + }); + }); + + describe('getByDerivationPath', () => { + it('returns null if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getByDerivationPath(mockDerivationPath); + + expect(result).toBeNull(); + }); + + it('returns account if derivation path exists', async () => { + mockSnapClient.getState.mockResolvedValue('some-id'); + + const result = await repo.getByDerivationPath(mockDerivationPath); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + "derivationPaths.m/84'/0'/0'", + ); + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts.some-id'); + expect(result).toBe(mockAccount); + }); + }); + + describe('getByDerivationPaths', () => { + const derivationPath1 = ['m', "84'", "0'", "1'"]; + const derivationPath2 = ['m', "84'", "0'", "2'"]; + const accountState1 = { + ...mockAccountState, + derivationPath: derivationPath1, + }; + const accountState2 = { + ...mockAccountState, + derivationPath: derivationPath2, + }; + const mockAccount1 = mock({ + ...mockAccount, + id: 'some-id-1', + derivationPath: derivationPath1, + }); + const mockAccount2 = mock({ + ...mockAccount, + id: 'some-id-2', + derivationPath: derivationPath2, + }); + + it('returns accounts in derivation path order with one state read per namespace', async () => { + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountState1, + 'some-id-2': accountState2, + }); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount2) + .mockReturnValueOnce(mockAccount1); + + const result = await repo.getByDerivationPaths([ + derivationPath2, + derivationPath1, + ]); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('derivationPaths'); + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(mockSnapClient.getState).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount2, mockAccount1]); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('uses cached account metadata without loading BDK wallets', async () => { + const accountStateWithMetadata: AccountState = { + ...accountState1, + metadata: { + address: 'bc1qcached...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'cached-public-descriptor', + }, + }; + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountStateWithMetadata, + }); + (BdkAccountAdapter.load as jest.Mock).mockClear(); + (ChangeSet.from_json as jest.Mock).mockClear(); + + const result = await repo.getByDerivationPaths([derivationPath1]); + const account = result[0]; + + expect(account?.id).toBe('some-id-1'); + expect(account?.publicAddress.toString()).toBe('bc1qaddress...'); + expect(account?.publicDescriptor).toBe('cached-public-descriptor'); + expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith( + 'bc1qcached...', + 'bitcoin', + ); + expect(ChangeSet.from_json).not.toHaveBeenCalled(); + expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); + }); + + it('repairs missing derivation path indexes from account state', async () => { + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountState1, + 'some-id-2': accountState2, + }); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getByDerivationPaths([ + derivationPath1, + derivationPath2, + ]); + + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', { + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }); + }); + + it('repairs a missing derivation path index for a single lookup', async () => { + mockSnapClient.getState.mockResolvedValueOnce({}).mockResolvedValueOnce({ + 'some-id-1': accountState1, + }); + (BdkAccountAdapter.load as jest.Mock).mockReturnValueOnce(mockAccount1); + + const result = await repo.getByDerivationPaths([derivationPath1]); + + expect(result).toStrictEqual([mockAccount1]); + expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', { + "m/84'/0'/1'": 'some-id-1', + }); + }); + }); + + describe('getWithSigner', () => { + it('returns null if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getWithSigner('some-id'); + + expect(result).toBeNull(); + }); + + it('returns account with signer if account exists', async () => { + mockSnapClient.getState.mockResolvedValue(mockAccountState); + + const result = await repo.getWithSigner('some-id'); + + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith( + mockDerivationPath, + ); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(BdkAccountAdapter.load).toHaveBeenLastCalledWith( + 'some-id', + mockDerivationPath, + mockChangeSet, + mockDescriptors, + ); + expect(result).toBe(mockAccount); + }); + }); + + describe('create', () => { + it('creates a new account with xpub', async () => { + const result = await repo.create(mockDerivationPath, 'bitcoin', 'p2wpkh'); + + expect(BdkAccountAdapter.create).toHaveBeenCalledWith( + 'mock-uuid', + mockDerivationPath, + mockDescriptors, + 'bitcoin', + ); + expect(result).toBe(mockAccount); + }); + }); + + describe('insert', () => { + it('throws an error if no wallet data', async () => { + await expect( + repo.insert({ + ...mockAccount, + takeStaged: jest.fn().mockReturnValue(undefined), + }), + ).rejects.toThrow( + 'Missing changeset data for account "some-id" for insertion.', + ); + }); + + it('inserts an account', async () => { + await repo.insert(mockAccount); + + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 1, + "derivationPaths.m/84'/0'/0'", + mockAccount.id, + ); + expect(mockSnapClient.setState).toHaveBeenLastCalledWith( + 'accounts.some-id', + { + wallet: mockWalletData, + inscriptions: [], + derivationPath: mockDerivationPath, + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor', + }, + }, + ); + }); + }); + + describe('insertMany', () => { + it('returns an empty array when there are no accounts to insert', async () => { + const result = await repo.insertMany([]); + + expect(result).toStrictEqual([]); + expect(mockSnapClient.getState).not.toHaveBeenCalled(); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('throws an error if any account has no wallet data', async () => { + await expect( + repo.insertMany([ + { + ...mockAccount, + id: 'missing-wallet', + takeStaged: jest.fn().mockReturnValue(undefined), + }, + mockAccount, + ]), + ).rejects.toThrow( + 'Missing changeset data for account "missing-wallet" for insertion.', + ); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('throws an error without consuming staged data if any account has no wallet data', async () => { + const accountWithWalletData = mock({ + id: 'some-id-1', + derivationPath: ['m', "84'", "0'", "1'"], + }); + (accountWithWalletData.hasStaged as jest.Mock) = jest + .fn() + .mockReturnValue(true); + (accountWithWalletData.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + const missingWalletAccount = mock({ + id: 'missing-wallet', + derivationPath: ['m', "84'", "0'", "2'"], + }); + (missingWalletAccount.hasStaged as jest.Mock) = jest + .fn() + .mockReturnValue(false); + (missingWalletAccount.takeStaged as jest.Mock) = jest.fn(); + + await expect( + repo.insertMany([accountWithWalletData, missingWalletAccount]), + ).rejects.toThrow( + 'Missing changeset data for account "missing-wallet" for insertion.', + ); + + expect(accountWithWalletData.hasStaged).toHaveBeenCalled(); + expect(missingWalletAccount.hasStaged).toHaveBeenCalled(); + expect(accountWithWalletData.takeStaged).not.toHaveBeenCalled(); + expect(missingWalletAccount.takeStaged).not.toHaveBeenCalled(); + expect(mockSnapClient.getState).not.toHaveBeenCalled(); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('inserts multiple accounts with one accounts write and one derivation path write', async () => { + const existingAccountState: AccountState = { + wallet: mockWalletData, + inscriptions: [], + derivationPath: mockDerivationPath, + }; + const account1 = mock(); + account1.id = 'some-id-1'; + account1.derivationPath = ['m', "84'", "0'", "1'"]; + account1.network = 'bitcoin'; + account1.addressType = 'p2wpkh'; + account1.publicAddress = mockAddress; + account1.publicDescriptor = 'mock-public-descriptor-1'; + const account2 = mock(); + account2.id = 'some-id-2'; + account2.derivationPath = ['m', "84'", "0'", "2'"]; + account2.network = 'bitcoin'; + account2.addressType = 'p2wpkh'; + account2.publicAddress = mockAddress; + account2.publicDescriptor = 'mock-public-descriptor-2'; + (account1.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (account1.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); + (account2.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (account2.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); + mockSnapClient.getState + .mockResolvedValueOnce({ + 'existing-id': existingAccountState, + }) + .mockResolvedValueOnce({ + "m/84'/0'/0'": 'existing-id', + }); + + const result = await repo.insertMany([account1, account2]); + + expect(result).toStrictEqual([account1, account2]); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith(1, 'accounts', { + 'existing-id': existingAccountState, + 'some-id-1': { + wallet: mockWalletData, + inscriptions: [], + derivationPath: ['m', "84'", "0'", "1'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-1', + }, + }, + 'some-id-2': { + wallet: mockWalletData, + inscriptions: [], + derivationPath: ['m', "84'", "0'", "2'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-2', + }, + }, + }); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 2, + 'derivationPaths', + { + "m/84'/0'/0'": 'existing-id', + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }, + ); + }); + }); + + describe('update', () => { + it('does nothing if no wallet data', async () => { + await repo.update({ + ...mockAccount, + takeStaged: jest.fn().mockReturnValue(undefined), + }); + + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('throws an error if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + await expect(repo.update(mockAccount)).rejects.toThrow( + 'Inconsistent state: account "some-id" not found for update', + ); + }); + + it('updates the account and inscriptions', async () => { + const mockInscription = mock(); + + mockSnapClient.getState.mockResolvedValue(mockWalletData); + + await repo.update(mockAccount, [mockInscription]); + + expect(mockChangeSet.merge).toHaveBeenCalled(); + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.wallet', + ); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 1, + 'accounts.some-id.wallet', + mockWalletData, + ); + expect(mockSnapClient.setState).toHaveBeenLastCalledWith( + 'accounts.some-id.inscriptions', + [mockInscription], + ); + }); + }); + + describe('delete', () => { + it('does nothing if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + await repo.delete('non-existent-id'); + + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('removes wallet data from store', async () => { + const accountState: AccountState = { + ...mockAccountState, + derivationPath: ['m', "84'", "0'", "0'"], + }; + + mockSnapClient.getState.mockResolvedValue(accountState); + + await repo.delete('some-id-1'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id-1', + ); + expect(mockSnapClient.setState).toHaveBeenCalledTimes(2); + expect(mockSnapClient.setState).toHaveBeenCalledWith( + "derivationPaths.m/84'/0'/0'", + null, + ); + expect(mockSnapClient.setState).toHaveBeenCalledWith( + 'accounts.some-id-1', + null, + ); + }); + }); + + describe('fetchInscriptions', () => { + it('returns null if inscriptions are not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.fetchInscriptions('non-existent-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.non-existent-id.inscriptions', + ); + expect(result).toBeNull(); + }); + + it('returns inscriptions when found', async () => { + const mockInscriptions: Inscription[] = [ + { + id: 'ins1', + number: 1, + contentLength: 100, + contentType: 'image/png', + satNumber: 1000, + satRarity: 'common', + location: 'txid1:vout:offset', + }, + { + id: 'ins2', + number: 2, + contentLength: 200, + contentType: 'text/plain', + satNumber: 2000, + satRarity: 'uncommon', + location: 'txid2:vout:offset', + }, + ]; + + mockSnapClient.getState.mockResolvedValue(mockInscriptions); + + const result = await repo.fetchInscriptions('some-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.inscriptions', + ); + expect(result).toStrictEqual(mockInscriptions); + }); + }); + + describe('getFrozenUTXOs', () => { + it('returns empty array if inscriptions is not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getFrozenUTXOs('non-existent-id'); + expect(result).toStrictEqual([]); + }); + + it('returns empty array if inscriptions is empty', async () => { + mockSnapClient.getState.mockResolvedValue([]); + + const result = await repo.getFrozenUTXOs('some-id'); + expect(result).toStrictEqual([]); + }); + + it('returns the list of frozen UTXO outpoints', async () => { + const mockInscriptions = [ + { location: 'txid1:vout:offset' }, + { location: 'txid2:vout:offset' }, + ] as Inscription[]; + + mockSnapClient.getState.mockResolvedValue(mockInscriptions); + + const result = await repo.getFrozenUTXOs('some-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.inscriptions', + ); + expect(result).toStrictEqual(['txid1:vout', 'txid2:vout']); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts new file mode 100644 index 00000000..252970ed --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -0,0 +1,406 @@ +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable camelcase */ + +import type { AddressType, Network } from '@metamask/bitcoindevkit'; +import { + ChangeSet, + slip10_to_extended, + xpriv_to_descriptor, + xpub_to_descriptor, +} from '@metamask/bitcoindevkit'; +import { v4 } from 'uuid'; + +import { + type BitcoinAccountRepository, + type BitcoinAccount, + type SnapClient, + type Inscription, + type AccountState, + type AccountMetadata, + type SnapState, + StorageError, +} from '../entities'; +import { BdkAccountAdapter, StoredAccountAdapter } from '../infra'; + +/** + * Encode a fingerprint to a 4-bytes hex-string (required by the BDK). + * + * @param fingerprint - The fingerprint to encode. + * @returns The encoded fingerprint. + */ +function toBdkFingerprint(fingerprint: number): string { + // READ THIS CAREFULLY: + // The BDK expects 4-bytes fingerprint, so we have to pad with 0, in case + // our fingerprint contains leading null-bytes. + return fingerprint.toString(16).padStart(8, '0'); +} + +/** + * @param derivationPath - Split derivation path. + * @returns Storage key for a derivation path. + */ +function getDerivationPathKey(derivationPath: string[]): string { + return derivationPath.join('/'); +} + +/** + * @param account - Account to persist. + * @returns Metadata needed for keyring account responses. + */ +function getAccountMetadata(account: BitcoinAccount): AccountMetadata { + return { + address: account.publicAddress.toString(), + addressType: account.addressType, + network: account.network, + publicDescriptor: account.publicDescriptor, + }; +} + +/** + * @param account - Account to persist. + * @param walletData - Serialized wallet data. + * @returns Account state with cached response metadata. + */ +function getAccountState( + account: BitcoinAccount, + walletData: ChangeSet, +): AccountState { + return { + wallet: walletData.to_json(), + inscriptions: [], + derivationPath: account.derivationPath, + metadata: getAccountMetadata(account), + }; +} + +export class BdkAccountRepository implements BitcoinAccountRepository { + readonly #snapClient: SnapClient; + + constructor(snapClient: SnapClient) { + this.#snapClient = snapClient; + } + + async get(id: string): Promise { + const account = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!account) { + return null; + } + + return this.#loadAccount(id, account); + } + + async getAll(): Promise { + const accounts = (await this.#snapClient.getState('accounts')) as + | SnapState['accounts'] + | null; + if (!accounts) { + return []; + } + + return Object.entries(accounts).flatMap(([id, account]) => + account ? [this.#loadAccount(id, account)] : [], + ); + } + + async getByDerivationPath( + derivationPath: string[], + ): Promise { + const id = await this.#snapClient.getState( + `derivationPaths.${getDerivationPathKey(derivationPath)}`, + ); + if (!id) { + return null; + } + + return this.get(id as string); + } + + async getByDerivationPaths( + derivationPaths: string[][], + ): Promise<(BitcoinAccount | null)[]> { + if (derivationPaths.length === 0) { + return []; + } + + const [derivationPathIndex, accounts] = await Promise.all([ + this.#snapClient.getState('derivationPaths') as Promise< + SnapState['derivationPaths'] | null + >, + this.#snapClient.getState('accounts') as Promise< + SnapState['accounts'] | null + >, + ]); + + const accountsById = accounts ?? {}; + const existingDerivationPathIndex = derivationPathIndex ?? {}; + const accountsByDerivationPath = new Map(); + + for (const [id, account] of Object.entries(accountsById)) { + if (account) { + accountsByDerivationPath.set( + getDerivationPathKey(account.derivationPath), + [id, account], + ); + } + } + + const repairs: Record = {}; + const results = derivationPaths.map((derivationPath) => { + const pathKey = getDerivationPathKey(derivationPath); + const indexedId = existingDerivationPathIndex[pathKey]; + const indexedAccount = indexedId ? accountsById[indexedId] : null; + + if (indexedId && indexedAccount) { + return this.#loadPersistedAccount(indexedId, indexedAccount); + } + + const fallback = accountsByDerivationPath.get(pathKey); + if (!fallback) { + return null; + } + + const [id, account] = fallback; + repairs[pathKey] = id; + return this.#loadPersistedAccount(id, account); + }); + + if (Object.keys(repairs).length > 0) { + await this.#snapClient.setState('derivationPaths', { + ...existingDerivationPathIndex, + ...repairs, + }); + } + + return results; + } + + async getWithSigner(id: string): Promise { + const accountState = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!accountState) { + return null; + } + + const { derivationPath, wallet } = accountState; + + const slip10 = await this.#snapClient.getPrivateEntropy(derivationPath); + const fingerprint = toBdkFingerprint( + slip10.masterFingerprint ?? slip10.parentFingerprint, + ); + + const account = BdkAccountAdapter.load( + id, + derivationPath, + ChangeSet.from_json(wallet), + ); + + const privDescriptors = xpriv_to_descriptor( + slip10_to_extended(slip10, account.network), + fingerprint, + account.network, + account.addressType, + ); + + return BdkAccountAdapter.load( + id, + derivationPath, + ChangeSet.from_json(wallet), + privDescriptors, + ); + } + + async create( + derivationPath: string[], + network: Network, + addressType: AddressType, + ): Promise { + const slip10 = await this.#snapClient.getPublicEntropy(derivationPath); + const id = v4(); + const fingerprint = toBdkFingerprint( + slip10.masterFingerprint ?? slip10.parentFingerprint, + ); + + const xpub = slip10_to_extended(slip10, network); + const descriptors = xpub_to_descriptor( + xpub, + fingerprint, + network, + addressType, + ); + + return BdkAccountAdapter.create(id, derivationPath, descriptors, network); + } + + async insert(account: BitcoinAccount): Promise { + const { id, derivationPath } = account; + const walletData = account.takeStaged(); + if (!walletData) { + throw new StorageError( + `Missing changeset data for account "${id}" for insertion.`, + ); + } + + await Promise.all([ + this.#snapClient.setState( + `derivationPaths.${getDerivationPathKey(derivationPath)}`, + id, + ), + this.#snapClient.setState( + `accounts.${id}`, + getAccountState(account, walletData), + ), + ]); + + return account; + } + + async insertMany(accounts: BitcoinAccount[]): Promise { + if (accounts.length === 0) { + return []; + } + + if (accounts.length === 1) { + return [await this.insert(accounts[0] as BitcoinAccount)]; + } + + const accountStateEntries: [string, AccountState][] = []; + const derivationPathEntries: [string, string][] = []; + + for (const account of accounts) { + if (!account.hasStaged()) { + throw new StorageError( + `Missing changeset data for account "${account.id}" for insertion.`, + ); + } + } + + for (const account of accounts) { + const { id, derivationPath } = account; + const walletData = account.takeStaged(); + + if (!walletData) { + throw new StorageError( + `Missing changeset data for account "${id}" for insertion.`, + ); + } + accountStateEntries.push([id, getAccountState(account, walletData)]); + derivationPathEntries.push([getDerivationPathKey(derivationPath), id]); + } + + const [existingAccounts, existingDerivationPaths] = await Promise.all([ + this.#snapClient.getState('accounts') as Promise< + SnapState['accounts'] | null + >, + this.#snapClient.getState('derivationPaths') as Promise< + SnapState['derivationPaths'] | null + >, + ]); + + await this.#snapClient.setState('accounts', { + ...(existingAccounts ?? {}), + ...Object.fromEntries(accountStateEntries), + }); + + await this.#snapClient.setState('derivationPaths', { + ...(existingDerivationPaths ?? {}), + ...Object.fromEntries(derivationPathEntries), + }); + + return accounts; + } + + async update( + account: BitcoinAccount, + inscriptions?: Inscription[], + ): Promise { + const { id } = account; + + const newWalletData = account.takeStaged(); + if (!newWalletData) { + // Nothing to update + return; + } + + const walletData = await this.#snapClient.getState(`accounts.${id}.wallet`); + if (!walletData) { + throw new StorageError( + `Inconsistent state: account "${id}" not found for update`, + ); + } + + newWalletData.merge(ChangeSet.from_json(walletData as string)); + await this.#snapClient.setState( + `accounts.${id}.wallet`, + newWalletData.to_json(), + ); + + // Inscriptions are overwritten and not merged + if (inscriptions) { + await this.#snapClient.setState( + `accounts.${id}.inscriptions`, + inscriptions, + ); + } + } + + async delete(id: string): Promise { + const account = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!account) { + return; + } + + const derivationPath = account.derivationPath.join('/'); + + await Promise.all([ + this.#snapClient.setState(`derivationPaths.${derivationPath}`, null), + this.#snapClient.setState(`accounts.${id}`, null), + ]); + } + + async fetchInscriptions(id: string): Promise { + const inscriptions = await this.#snapClient.getState( + `accounts.${id}.inscriptions`, + ); + if (!inscriptions) { + return null; + } + + return inscriptions as Inscription[]; + } + + async getFrozenUTXOs(id: string): Promise { + const inscriptions = await this.#snapClient.getState( + `accounts.${id}.inscriptions`, + ); + if (!inscriptions) { + return []; + } + + return (inscriptions as AccountState['inscriptions']).map((inscription) => { + // format: :: + const [txid, vout] = inscription.location.split(':'); + return `${txid}:${vout}`; + }); + } + + #loadAccount(id: string, account: AccountState): BitcoinAccount { + return BdkAccountAdapter.load( + id, + account.derivationPath, + ChangeSet.from_json(account.wallet), + ); + } + + #loadPersistedAccount(id: string, account: AccountState): BitcoinAccount { + if (StoredAccountAdapter.canLoad(account)) { + return StoredAccountAdapter.load(id, account); + } + + return this.#loadAccount(id, account); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts b/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts new file mode 100644 index 00000000..3fea3ef8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts @@ -0,0 +1,124 @@ +import type { Json } from '@metamask/snaps-sdk'; + +/** + * 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; + }; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; + +/** + * 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/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts new file mode 100644 index 00000000..a0e40d5f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts @@ -0,0 +1,528 @@ +import { InMemoryCache } from './InMemoryCache'; + +describe('InMemoryCache', () => { + let cache: InMemoryCache; + + beforeEach(() => { + cache = new InMemoryCache(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('set and get', () => { + it('stores and retrieves a value', async () => { + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('stores and retrieves complex objects', async () => { + const complexObject = { + nested: { data: [1, 2, 3] }, + string: 'test', + number: 42, + }; + + await cache.set('complex', complexObject); + const result = await cache.get('complex'); + + expect(result).toStrictEqual(complexObject); + }); + + it('stores null values', async () => { + await cache.set('null-key', null); + const result = await cache.get('null-key'); + + expect(result).toBeNull(); + }); + + it('returns undefined for non-existent keys', async () => { + const result = await cache.get('non-existent'); + + expect(result).toBeUndefined(); + }); + + it('overwrites existing values', async () => { + await cache.set('key1', 'value1'); + await cache.set('key1', 'value2'); + const result = await cache.get('key1'); + + expect(result).toBe('value2'); + }); + }); + + describe('TTL (Time To Live)', () => { + it('stores value with custom TTL', async () => { + await cache.set('key1', 'value1', 1000); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('expires value after TTL', async () => { + jest.useFakeTimers(); + const ttl = 1000; + + await cache.set('key1', 'value1', ttl); + + // Before expiration + expect(await cache.get('key1')).toBe('value1'); + + // After expiration + jest.advanceTimersByTime(ttl + 1); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('uses default TTL when not specified', async () => { + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('throws error for negative TTL', async () => { + await expect(cache.set('key1', 'value1', -100)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws error for non-numeric TTL', async () => { + await expect( + cache.set('key1', 'value1', 'invalid' as any), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws error for TTL greater than MAX_SAFE_INTEGER', async () => { + await expect( + cache.set('key1', 'value1', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + + it('handles TTL of zero', async () => { + jest.useFakeTimers(); + + await cache.set('key1', 'value1', 0); + + // Should be immediately expired + jest.advanceTimersByTime(1); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('clamps TTL to MAX_SAFE_INTEGER to prevent overflow', async () => { + const largeButValidTTL = Number.MAX_SAFE_INTEGER; + await cache.set('key1', 'value1', largeButValidTTL); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + }); + + describe('delete', () => { + it('deletes an existing key', async () => { + await cache.set('key1', 'value1'); + const deleted = await cache.delete('key1'); + + expect(deleted).toBe(true); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('returns false when deleting non-existent key', async () => { + const deleted = await cache.delete('non-existent'); + + expect(deleted).toBe(false); + }); + }); + + describe('has', () => { + it('returns true for existing keys', async () => { + await cache.set('key1', 'value1'); + + expect(await cache.has('key1')).toBe(true); + }); + + it('returns false for non-existent keys', async () => { + expect(await cache.has('non-existent')).toBe(false); + }); + + it('returns false and removes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + + expect(await cache.has('key1')).toBe(true); + + jest.advanceTimersByTime(1001); + + expect(await cache.has('key1')).toBe(false); + // Verify the key was actually removed + expect(await cache.get('key1')).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('removes all entries', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + await cache.clear(); + + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBeUndefined(); + expect(await cache.size()).toBe(0); + }); + + it('works on empty cache', async () => { + await cache.clear(); + expect(await cache.size()).toBe(0); + }); + }); + + describe('keys', () => { + it('returns all keys', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const keys = await cache.keys(); + + expect(keys).toHaveLength(3); + expect(keys).toContain('key1'); + expect(keys).toContain('key2'); + expect(keys).toContain('key3'); + }); + + it('returns empty array for empty cache', async () => { + const keys = await cache.keys(); + + expect(keys).toStrictEqual([]); + }); + + it('excludes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + await cache.set('key3', 'value3', 3000); + + jest.advanceTimersByTime(1500); + + const keys = await cache.keys(); + + expect(keys).toHaveLength(2); + expect(keys).not.toContain('key1'); + expect(keys).toContain('key2'); + expect(keys).toContain('key3'); + }); + }); + + describe('size', () => { + it('returns the number of entries', async () => { + expect(await cache.size()).toBe(0); + + await cache.set('key1', 'value1'); + expect(await cache.size()).toBe(1); + + await cache.set('key2', 'value2'); + expect(await cache.size()).toBe(2); + + await cache.delete('key1'); + expect(await cache.size()).toBe(1); + }); + + it('excludes expired entries', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + + expect(await cache.size()).toBe(2); + + jest.advanceTimersByTime(1500); + + expect(await cache.size()).toBe(1); + }); + }); + + describe('peek', () => { + it('retrieves value without affecting TTL', async () => { + await cache.set('key1', 'value1'); + const result = await cache.peek('key1'); + + expect(result).toBe('value1'); + }); + + it('returns undefined for non-existent keys', async () => { + const result = await cache.peek('non-existent'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and removes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + + expect(await cache.peek('key1')).toBe('value1'); + + jest.advanceTimersByTime(1001); + + expect(await cache.peek('key1')).toBeUndefined(); + // Verify the key was actually removed + expect(await cache.get('key1')).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('retrieves multiple values', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const result = await cache.mget(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: 'value1', + key2: 'value2', + key3: 'value3', + }); + }); + + it('returns undefined for non-existent keys', async () => { + await cache.set('key1', 'value1'); + + const result = await cache.mget(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: 'value1', + key2: undefined, + key3: undefined, + }); + }); + + it('handles empty key array', async () => { + const result = await cache.mget([]); + + expect(result).toStrictEqual({}); + }); + + it('excludes expired entries', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + + jest.advanceTimersByTime(1500); + + const result = await cache.mget(['key1', 'key2']); + + expect(result).toStrictEqual({ + key1: undefined, + key2: 'value2', + }); + }); + }); + + describe('mset', () => { + it('stores multiple values', async () => { + await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + { key: 'key3', value: 'value3' }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + expect(await cache.get('key2')).toBe('value2'); + expect(await cache.get('key3')).toBe('value3'); + }); + + it('stores multiple values with different TTLs', async () => { + jest.useFakeTimers(); + await cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + { key: 'key2', value: 'value2', ttlMilliseconds: 2000 }, + ]); + + jest.advanceTimersByTime(1500); + + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBe('value2'); + }); + + it('skips undefined values', async () => { + await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: undefined }, + { key: 'key3', value: 'value3' }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBe('value3'); + expect(await cache.size()).toBe(2); + }); + + it('stores null values', async () => { + await cache.mset([{ key: 'key1', value: null }]); + + expect(await cache.get('key1')).toBeNull(); + }); + + it('handles empty array', async () => { + await cache.mset([]); + expect(await cache.size()).toBe(0); + }); + + it('handles single entry (delegates to set)', async () => { + await cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + }); + + it('throws error if any TTL is invalid', async () => { + await expect( + cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + { key: 'key2', value: 'value2', ttlMilliseconds: -100 }, + ]), + ).rejects.toThrow('TTL must be positive'); + + // Verify no values were set + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + }); + }); + + describe('mdelete', () => { + it('deletes multiple keys', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const result = await cache.mdelete(['key1', 'key2']); + + expect(result).toStrictEqual({ + key1: true, + key2: true, + }); + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBe('value3'); + }); + + it('returns false for non-existent keys', async () => { + await cache.set('key1', 'value1'); + + const result = await cache.mdelete(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: true, + key2: false, + key3: false, + }); + }); + + it('handles empty array', async () => { + const result = await cache.mdelete([]); + + expect(result).toStrictEqual({}); + }); + }); + + describe('edge cases and integration', () => { + it('handles rapid successive operations', async () => { + await cache.set('key1', 'value1'); + await cache.set('key1', 'value2'); + await cache.set('key1', 'value3'); + + expect(await cache.get('key1')).toBe('value3'); + }); + + it('handles large number of entries', async () => { + const entries = Array.from({ length: 1000 }, (_, i) => ({ + key: `key${i}`, + value: `value${i}`, + })); + + await cache.mset(entries); + + expect(await cache.size()).toBe(1000); + expect(await cache.get('key500')).toBe('value500'); + }); + + it('maintains separate entries for similar keys', async () => { + await cache.set('key', 'value1'); + await cache.set('key1', 'value2'); + await cache.set('key10', 'value3'); + + expect(await cache.get('key')).toBe('value1'); + expect(await cache.get('key1')).toBe('value2'); + expect(await cache.get('key10')).toBe('value3'); + }); + + it('handles mixed operations with expiration', async () => { + jest.useFakeTimers(); + + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + await cache.set('key3', 'value3'); + + jest.advanceTimersByTime(1500); + + await cache.delete('key3'); + await cache.set('key4', 'value4'); + + expect(await cache.size()).toBe(2); // key2 and key4 + expect(await cache.keys()).toStrictEqual(['key2', 'key4']); + }); + + it('handles special characters in keys', async () => { + const specialKeys = [ + 'key:with:colons', + 'key.with.dots', + 'key-with-dashes', + 'key_with_underscores', + 'key with spaces', + 'key/with/slashes', + ]; + + for (const key of specialKeys) { + await cache.set(key, `value-${key}`); + } + + for (const key of specialKeys) { + expect(await cache.get(key)).toBe(`value-${key}`); + } + }); + + it('handles bigint values', async () => { + const bigIntValue = BigInt(9007199254740991); + await cache.set('bigint', bigIntValue); + + expect(await cache.get('bigint')).toBe(bigIntValue); + }); + + it('handles Uint8Array values', async () => { + const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); + await cache.set('uint8array', uint8Array); + + const result = await cache.get('uint8array'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toStrictEqual(uint8Array); + }); + + it('handles nested arrays and objects', async () => { + const complexValue = { + array: [1, 2, { nested: 'value' }], + object: { a: 1, b: { c: 2 } }, + mixed: [{ x: 1 }, { y: 2 }], + }; + + await cache.set('complex', complexValue); + + expect(await cache.get('complex')).toStrictEqual(complexValue); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts new file mode 100644 index 00000000..73657ce8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts @@ -0,0 +1,171 @@ +import { assert } from '@metamask/utils'; + +import type { CacheEntry, ICache, Serializable } from './ICache'; + +/** + * 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(); + + #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) { + result[key] = undefined; + continue; + } + + 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/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx new file mode 100644 index 00000000..8d7d7db1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx @@ -0,0 +1,525 @@ +import type { + Address, + Amount, + Psbt, + ScriptBuf, + Transaction, + TxOut, +} from '@metamask/bitcoindevkit'; +import { Address as BdkAddress } from '@metamask/bitcoindevkit'; +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { + AssetRatesClient, + SnapClient, + Translator, + BitcoinAccount, + BlockchainClient, + Logger, + SpotPrice, +} from '../entities'; +import { networkToCurrencyUnit } from '../entities'; +import { JSXConfirmationRepository } from './JSXConfirmationRepository'; +import { SignMessageConfirmationView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_script: jest.fn(), + from_string: jest.fn(), + }, +})); + +const MockedBdkAddress = jest.mocked(BdkAddress); + +jest.mock('../infra/jsx', () => ({ + SignMessageConfirmationView: jest.fn(), + SignPsbtConfirmationView: jest.fn(), +})); + +jest.mock('../infra/jsx/unified-send-flow', () => ({ + UnifiedSendFormView: jest.fn(), +})); + +describe('JSXConfirmationRepository', () => { + const mockMessages = { foo: { message: 'bar' } }; + const mockSnapClient = mock(); + const mockTranslator = mock(); + const mockChainClient = mock(); + const mockRatesClient = mock(); + const mockLogger = mock(); + + const repo = new JSXConfirmationRepository( + mockSnapClient, + mockTranslator, + mockChainClient, + mockRatesClient, + mockLogger, + ); + + describe('insertSignMessage', () => { + const mockAccount = mock({ + id: 'account-id', + publicAddress: mock
({ toString: () => 'myAddress' }), + }); + const message = 'message'; + const origin = 'origin'; + const expectedContext = { + message, + origin, + account: { + id: mockAccount.id, + address: mockAccount.publicAddress.toString(), + }, + network: mockAccount.network, + }; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en' }), + ); + }); + + it('creates and displays a sign message interface', async () => { + await repo.insertSignMessage(mockAccount, message, origin); + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'interface-id', + ); + }); + + it('throws UserActionError if the interface returns false', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSignMessage(mockAccount, message, origin), + ).rejects.toThrow('User canceled the confirmation'); + }); + }); + + describe('insertSendTransfer', () => { + const mockRecipientScript = mock(); + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => false, + }); + const mockPsbt = mock({ + toString: () => 'serialized-psbt', + }); + const recipient = { address: 'toAddress', amount: '50000' }; + const origin = 'dapp-origin'; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('send-interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en', currency: 'usd' }), + ); + mockChainClient.getExplorerUrl.mockReturnValue('https://mempool.space'); + mockRatesClient.spotPrices.mockResolvedValue( + mock({ price: 50000 }), + ); + MockedBdkAddress.from_string.mockReturnValue( + mock
({ script_pubkey: mockRecipientScript }), + ); + }); + + it('creates and displays a send transfer interface', async () => { + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + const expectedContext = { + from: 'fromAddress', + explorerUrl: 'https://mempool.space', + network: mockAccount.network, + currency: networkToCurrencyUnit[mockAccount.network], + exchangeRate: expect.objectContaining({ + conversionRate: 50000, + currency: 'USD', + }), + recipient: recipient.address, + amount: recipient.amount, + locale: 'en', + psbt: 'serialized-psbt', + origin, + isMine: false, + }; + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockChainClient.getExplorerUrl).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'send-interface-id', + ); + }); + + it('marks the recipient as isMine when it belongs to the account', async () => { + const selfSendAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => true, + }); + + await repo.insertSendTransfer( + selfSendAccount, + mockPsbt, + recipient, + origin, + ); + + expect(MockedBdkAddress.from_string).toHaveBeenCalledWith( + recipient.address, + selfSendAccount.network, + ); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ isMine: true }), + ); + }); + + it('defaults isMine to false when the recipient address fails to parse', async () => { + const addressError = new Error('Invalid address'); + MockedBdkAddress.from_string.mockImplementation(() => { + throw addressError; + }); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + addressError, + ); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ isMine: false }), + ); + }); + + it('throws UserActionError if the user cancels', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('sets exchangeRate to undefined for non-mainnet networks', async () => { + const testnetAccount = mock({ + id: 'account-id', + network: 'testnet', + publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => false, + }); + + await repo.insertSendTransfer( + testnetAccount, + mockPsbt, + recipient, + origin, + ); + + expect(mockRatesClient.spotPrices).not.toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when spot price is null', async () => { + // @ts-expect-error - testing runtime guard against API returning null + mockRatesClient.spotPrices.mockResolvedValue({ price: null }); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when rates client throws', async () => { + const ratesError = new Error('API error'); + mockRatesClient.spotPrices.mockRejectedValue(ratesError); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(ratesError); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + }); + + describe('insertSignPsbt', () => { + const mockScriptRecipient = mock({ + is_op_return: () => false, + }); + const mockTxOut = mock({ + value: mock({ + to_sat: () => BigInt(1000), + }), + script_pubkey: mockScriptRecipient, + }); + + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => false, + }); + + const mockSignPsbt = mock({ + toString: () => 'psbt-base64-string', + fee_amount: () => + mock({ + to_sat: () => BigInt(500), + }), + unsigned_tx: mock({ + output: [mockTxOut], + input: [{}], + }), + }); + + const options = { fill: true, broadcast: false }; + const origin = 'https://dapp.example.com'; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('psbt-interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en', currency: 'usd' }), + ); + mockRatesClient.spotPrices.mockResolvedValue( + mock({ price: 50000 }), + ); + MockedBdkAddress.from_script.mockReturnValue( + mock
({ toString: () => 'resolved-address' }), + ); + }); + + it('creates and displays a sign PSBT confirmation interface with parsed outputs', async () => { + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + fee: '500', + currency: networkToCurrencyUnit[mockAccount.network], + exchangeRate: expect.objectContaining({ + conversionRate: 50000, + currency: 'USD', + }), + outputs: [ + { + address: 'resolved-address', + amount: '1000', + isMine: false, + isOpReturn: false, + }, + ], + inputCount: 1, + }), + ); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'psbt-interface-id', + ); + }); + + it('marks change outputs with isMine true', async () => { + const changeAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => true, + }); + + await repo.insertSignPsbt(changeAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [expect.objectContaining({ isMine: true })], + }), + ); + }); + + it('handles OP_RETURN outputs', async () => { + const opReturnScript = mock({ + is_op_return: () => true, + }); + const opReturnOut = mock({ + value: mock({ + to_sat: () => BigInt(0), + }), + script_pubkey: opReturnScript, + }); + const psbtWithOpReturn = mock({ + toString: () => 'psbt-op-return', + fee_amount: () => + mock({ + to_sat: () => BigInt(300), + }), + unsigned_tx: mock({ + output: [opReturnOut], + input: [], + }), + }); + + await repo.insertSignPsbt(mockAccount, psbtWithOpReturn, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [ + { + address: undefined, + amount: '0', + isMine: false, + isOpReturn: true, + }, + ], + inputCount: 0, + }), + ); + }); + + it('throws UserActionError if the user cancels', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('handles PSBT without fee information gracefully', async () => { + const psbtNoFee = mock({ + toString: () => 'psbt-no-fee', + fee_amount: () => undefined as unknown as Amount, + unsigned_tx: mock({ + output: [], + input: [], + }), + }); + await repo.insertSignPsbt(mockAccount, psbtNoFee, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ fee: undefined }), + ); + }); + + it('handles PSBT fee_amount throwing an error gracefully', async () => { + const feeError = new Error('Missing TxOut data'); + const psbtFeeError = mock({ + toString: () => 'psbt-fee-error', + fee_amount: () => { + throw feeError; + }, + unsigned_tx: mock({ + output: [], + input: [], + }), + }); + await repo.insertSignPsbt(mockAccount, psbtFeeError, origin, options); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(feeError); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ fee: undefined }), + ); + }); + + it('sets exchangeRate to undefined for non-mainnet networks', async () => { + const testnetAccount = mock({ + id: 'account-id', + network: 'testnet', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => false, + }); + + await repo.insertSignPsbt(testnetAccount, mockSignPsbt, origin, options); + + expect(mockRatesClient.spotPrices).not.toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when spot price is null', async () => { + // @ts-expect-error - testing runtime guard against API returning null + mockRatesClient.spotPrices.mockResolvedValue({ price: null }); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when rates client throws', async () => { + const ratesError = new Error('API error'); + mockRatesClient.spotPrices.mockRejectedValue(ratesError); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(ratesError); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets address to undefined when BdkAddress.from_script throws', async () => { + const scriptError = new Error('Unrecognized script'); + MockedBdkAddress.from_script.mockImplementation(() => { + throw scriptError; + }); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + scriptError, + ); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [ + { + address: undefined, + amount: '1000', + isMine: false, + isOpReturn: false, + }, + ], + }), + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx new file mode 100644 index 00000000..35cda5c2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -0,0 +1,236 @@ +import type { Psbt } from '@metamask/bitcoindevkit'; +import { Address as BdkAddress } from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; + +import type { + AssetRatesClient, + BitcoinAccount, + BlockchainClient, + ConfirmationRepository, + ConfirmSendFormContext, + CurrencyRate, + Logger, + SignMessageConfirmationContext, + SignPsbtConfirmationContext, + SignPsbtOutput, + SnapClient, + Translator, +} from '../entities'; +import { networkToCurrencyUnit, UserActionError } from '../entities'; +import { + SignMessageConfirmationView, + SignPsbtConfirmationView, +} from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; + +export class JSXConfirmationRepository implements ConfirmationRepository { + readonly #snapClient: SnapClient; + + readonly #translator: Translator; + + readonly #chainClient: BlockchainClient; + + readonly #ratesClient: AssetRatesClient; + + readonly #logger: Logger; + + constructor( + snapClient: SnapClient, + translator: Translator, + chainClient: BlockchainClient, + ratesClient: AssetRatesClient, + logger: Logger, + ) { + this.#snapClient = snapClient; + this.#translator = translator; + this.#chainClient = chainClient; + this.#ratesClient = ratesClient; + this.#logger = logger; + } + + async insertSignMessage( + account: BitcoinAccount, + message: string, + origin: string, + ): Promise { + const { locale } = await this.#snapClient.getPreferences(); + const context: SignMessageConfirmationContext = { + message, + origin, + account: { + id: account.id, + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow + }, + network: account.network, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + // Blocks and waits for user actions. This logic can live here instead of in the use case + // because it's common to all confirmations. Move to use case if needed. + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + let isMine = false; + try { + const recipientScript = BdkAddress.from_string( + recipient.address, + account.network, + ).script_pubkey; + isMine = account.isMine(recipientScript); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + isMine = false; + } + + const context: ConfirmSendFormContext = { + from: account.publicAddress.toString(), + explorerUrl: this.#chainClient.getExplorerUrl(account.network), + network: account.network, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + recipient: recipient.address, + amount: recipient.amount, + locale, + psbt: psbt.toString(), + origin, + isMine, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + let fee: string | undefined; + try { + const feeAmount = psbt.fee_amount(); + if (feeAmount) { + fee = feeAmount.to_sat().toString(); + } + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + fee = undefined; + } + + const outputs: SignPsbtOutput[] = []; + for (const txout of psbt.unsigned_tx.output) { + const isOpReturn = txout.script_pubkey.is_op_return(); + const isMine = account.isMine(txout.script_pubkey); + + let address: string | undefined; + if (!isOpReturn) { + try { + address = BdkAddress.from_script( + txout.script_pubkey, + account.network, + ).toString(); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + address = undefined; + } + } + + outputs.push({ + address, + amount: txout.value.to_sat().toString(), + isMine, + isOpReturn, + }); + } + + const context: SignPsbtConfirmationContext = { + psbt: psbt.toString(), + origin, + account: { + id: account.id, + address: account.publicAddress.toString(), + }, + network: account.network, + options, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + fee, + outputs, + inputCount: psbt.unsigned_tx.input.length, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async #getExchangeRate( + network: string, + currency: string, + ): Promise { + if (network !== 'bitcoin') { + return undefined; + } + + try { + const spotPrice = await this.#ratesClient.spotPrices(currency); + + if (spotPrice.price === undefined || spotPrice.price === null) { + return undefined; + } + + return { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + // Exchange rates are optional display information - don't fail if unavailable + this.#logger.warn( + `Failed to fetch spot price for currency ${currency}`, + error, + ); + return undefined; + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx new file mode 100644 index 00000000..ad3ea611 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -0,0 +1,125 @@ +import { mock } from 'jest-mock-extended'; + +import type { + SnapClient, + SendFormContext, + ConfirmSendFormContext, + ReviewTransactionContext, + Translator, +} from '../entities'; +import { JSXSendFlowRepository } from './JSXSendFlowRepository'; +import { ReviewTransactionView, SendFormView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; + +jest.mock('../infra/jsx', () => ({ + SendFormView: jest.fn(), + ReviewTransactionView: jest.fn(), +})); + +jest.mock('../infra/jsx/unified-send-flow', () => ({ + UnifiedSendFormView: jest.fn(), +})); + +describe('JSXSendFlowRepository', () => { + const mockMessages = { foo: { message: 'bar' } }; + + const mockSnapClient = mock(); + const mockTranslator = mock(); + + const repo = new JSXSendFlowRepository(mockSnapClient, mockTranslator); + + describe('getContext', () => { + it('returns context if found', async () => { + const context = { foo: 'bar' }; + const id = 'test-id'; + mockSnapClient.getInterfaceContext.mockResolvedValue(context); + + const result = await repo.getContext(id); + + expect(mockSnapClient.getInterfaceContext).toHaveBeenCalledWith(id); + expect(result).toStrictEqual(context); + }); + + it('throws AssertionError if context is null', async () => { + mockSnapClient.getInterfaceContext.mockResolvedValue(null); + await expect(repo.getContext('test-id')).rejects.toThrow( + 'Missing context in send flow interface', + ); + }); + + it('propagates error from getInterfaceContext', async () => { + const error = new Error('getInterfaceContext failed'); + mockSnapClient.getInterfaceContext.mockRejectedValue(error); + + await expect(repo.getContext('test-id')).rejects.toBe(error); + }); + }); + + describe('insertForm', () => { + it('creates interface with correct context', async () => { + const mockContext = mock({ locale: 'en' }); + mockSnapClient.createInterface.mockResolvedValue('interface-id'); + mockTranslator.load.mockResolvedValue(mockMessages); + + const result = await repo.insertForm(mockContext); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + mockContext, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(result).toBe('interface-id'); + }); + }); + + describe('updateForm', () => { + it('updates interface with context', async () => { + const id = 'interface-id'; + const mockContext = mock({ locale: 'de' }); + mockTranslator.load.mockResolvedValue(mockMessages); + + await repo.updateForm(id, mockContext); + + expect(mockTranslator.load).toHaveBeenCalledWith('de'); + expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( + id, + , + mockContext, + ); + }); + }); + + describe('updateReview', () => { + it('updates interface with context', async () => { + const id = 'interface-id'; + const mockContext = mock({ locale: 'fr' }); + mockTranslator.load.mockResolvedValue(mockMessages); + + await repo.updateReview(id, mockContext); + + expect(mockTranslator.load).toHaveBeenCalledWith('fr'); + expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( + id, + , + mockContext, + ); + }); + }); + + describe('insertConfirmSendForm', () => { + it('creates interface with confirm send form context', async () => { + const mockContext = mock({ locale: 'en' }); + mockSnapClient.createInterface.mockResolvedValue('confirm-interface-id'); + mockTranslator.load.mockResolvedValue(mockMessages); + + const result = await repo.insertConfirmSendForm(mockContext); + + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + mockContext, + ); + expect(result).toBe('confirm-interface-id'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx new file mode 100644 index 00000000..2ba4984e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -0,0 +1,71 @@ +import { + type ConfirmSendFormContext, + type SendFormContext, + type SendFlowRepository, + type SnapClient, + type ReviewTransactionContext, + type Translator, + AssertionError, +} from '../entities'; +import { ReviewTransactionView, SendFormView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; + +export class JSXSendFlowRepository implements SendFlowRepository { + readonly #snapClient: SnapClient; + + readonly #translator: Translator; + + constructor(snapClient: SnapClient, translator: Translator) { + this.#snapClient = snapClient; + this.#translator = translator; + } + + async getContext(id: string): Promise { + const context = await this.#snapClient.getInterfaceContext(id); + if (!context) { + throw new AssertionError('Missing context in send flow interface'); + } + + return context as SendFormContext; + } + + async insertForm(context: SendFormContext): Promise { + const messages = await this.#translator.load(context.locale); + return this.#snapClient.createInterface( + , + context, + ); + } + + async updateForm(id: string, context: SendFormContext): Promise { + const messages = await this.#translator.load(context.locale); + return this.#snapClient.updateInterface( + id, + , + context, + ); + } + + async updateReview( + id: string, + context: ReviewTransactionContext, + ): Promise { + const messages = await this.#translator.load(context.locale); + return this.#snapClient.updateInterface( + id, + , + context, + ); + } + + async insertConfirmSendForm( + context: ConfirmSendFormContext, + ): Promise { + const messages = await this.#translator.load(context.locale); + + return await this.#snapClient.createInterface( + , + context, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/index.ts b/merged-packages/bitcoin-wallet-snap/src/store/index.ts new file mode 100644 index 00000000..c23e9e14 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/index.ts @@ -0,0 +1,2 @@ +export * from './BdkAccountRepository'; +export * from './JSXSendFlowRepository'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts new file mode 100644 index 00000000..293c74af --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -0,0 +1,2077 @@ +import type { + FeeEstimates, + TxOut, + ScriptBuf, + Amount, + Transaction, + Txid, + WalletTx, + AddressType, + Network, + Psbt, + Address, +} from '@metamask/bitcoindevkit'; +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import { mock } from 'jest-mock-extended'; + +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + ConfirmationRepository, + Inscription, + Logger, + MetaProtocolsClient, + SnapClient, + TransactionBuilder, +} from '../entities'; +import { + AccountCapability, + TrackingSnapEvent, + ValidationError, +} from '../entities'; +import type { + CreateAccountParams, + DiscoverAccountParams, +} from './AccountUseCases'; +import { AccountUseCases } from './AccountUseCases'; +import { CronMethod } from '../handlers/CronHandler'; + +describe('AccountUseCases', () => { + const mockLogger = mock(); + const mockSnapClient = mock(); + const mockRepository = mock(); + const mockConfirmationRepository = mock(); + const mockChain = mock(); + const mockMetaProtocols = mock(); + const fallbackFeeRate = 5.0; + const targetBlocksConfirmation = 3; + + const useCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + mockMetaProtocols, + ); + + describe('get', () => { + it('returns account', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + + mockRepository.get.mockResolvedValue(mockAccount); + + const result = await useCases.get('some-id'); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(result).toBe(mockAccount); + }); + + it('throws Error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect(useCases.get('some-id')).rejects.toThrow( + 'Account not found', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + }); + + it('propagates an error if the repository get fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + + const error = new Error('Get failed'); + mockRepository.get.mockRejectedValue(error); + + await expect(useCases.get('some-id')).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + }); + }); + + describe('list', () => { + it('returns accounts', async () => { + const mockAccount = mock(); + + mockRepository.getAll.mockResolvedValue([mockAccount]); + + const result = await useCases.list(); + + expect(mockRepository.getAll).toHaveBeenCalled(); + expect(result).toStrictEqual([mockAccount]); + }); + + it('propagates an error if the repository getAll fails', async () => { + const error = new Error('Get failed'); + mockRepository.getAll.mockRejectedValue(error); + + await expect(useCases.list()).rejects.toBe(error); + + expect(mockRepository.getAll).toHaveBeenCalled(); + }); + }); + + describe('create', () => { + const createParams: CreateAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + synchronize: false, + correlationId: 'correlation-id', + accountName: 'My account', + }; + const mockAccount = mock({ network: createParams.network }); + + beforeEach(() => { + mockRepository.create.mockResolvedValue(mockAccount); + }); + + it.each([ + { tAddressType: 'p2pkh', purpose: "44'" }, + { tAddressType: 'p2sh', purpose: "49'" }, + { tAddressType: 'p2wsh', purpose: "45'" }, + { tAddressType: 'p2wpkh', purpose: "84'" }, + { tAddressType: 'p2tr', purpose: "86'" }, + ] as { tAddressType: AddressType; purpose: string }[])( + 'creates an account of type: %s', + async ({ tAddressType, purpose }) => { + const derivationPath = [ + createParams.entropySource, + purpose, + "0'", + `${createParams.index}'`, + ]; + + await useCases.create({ + ...createParams, + addressType: tAddressType, + synchronize: true, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + derivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + derivationPath, + createParams.network, + tAddressType, + ); + expect(mockAccount.revealNextAddress).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + createParams.correlationId, + createParams.accountName, + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: mockAccount.id }, + }); + }, + ); + + it.each([ + { tNetwork: 'bitcoin', coinType: "0'" }, + { tNetwork: 'testnet', coinType: "1'" }, + { tNetwork: 'testnet4', coinType: "1'" }, + { tNetwork: 'signet', coinType: "1'" }, + { tNetwork: 'regtest', coinType: "1'" }, + ] as { tNetwork: Network; coinType: string }[])( + 'should create an account on network: %s', + async ({ tNetwork, coinType }) => { + const expectedDerivationPath = [ + createParams.entropySource, + "84'", + coinType, + `${createParams.index}'`, + ]; + + await useCases.create({ + ...createParams, + network: tNetwork, + synchronize: true, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + expectedDerivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + expectedDerivationPath, + tNetwork, + createParams.addressType, + ); + expect(mockRepository.insert).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + createParams.correlationId, + createParams.accountName, + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: mockAccount.id }, + }); + }, + ); + + it('returns an existing account if one already exists on same network', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(mockAccount); + + const result = await useCases.create(createParams); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); + + expect(result).toBe(mockAccount); + }); + + it('propagates an error if getByDerivationPath throws', async () => { + const error = new Error('getByDerivationPath failed'); + mockRepository.getByDerivationPath.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); + }); + + it('propagates an error if create throws', async () => { + const error = new Error('create failed'); + mockRepository.create.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + }); + + it('propagates an error if insert throws', async () => { + const error = new Error('insert failed'); + mockRepository.insert.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + }); + + it('propagates an error if emitAccountCreatedEvent throws', async () => { + const error = new Error('emitAccountCreatedEvent failed'); + mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); + }); + + it('propagates an error if scheduleBackgroundEvent throws', async () => { + const error = new Error('scheduleBackgroundEvent failed'); + mockSnapClient.scheduleBackgroundEvent.mockRejectedValue(error); + + await expect( + useCases.create({ ...createParams, synchronize: true }), + ).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + }); + }); + + describe('createMany', () => { + const createParams: CreateAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + synchronize: false, + correlationId: 'correlation-id', + accountName: 'My account', + }; + const firstDerivationPath = ['some-source', "84'", "0'", "1'"]; + const secondDerivationPath = ['some-source', "84'", "0'", "2'"]; + const existingAccount = mock({ + id: 'existing-id', + network: createParams.network, + }); + const newAccount = mock({ + id: 'new-id', + network: createParams.network, + }); + + it('reuses existing accounts and bulk-inserts newly-created accounts', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([ + existingAccount, + null, + ]); + mockRepository.create.mockResolvedValue(newAccount); + + const result = await useCases.createMany([ + createParams, + { ...createParams, index: 2, synchronize: true }, + ]); + + expect(mockRepository.getByDerivationPaths).toHaveBeenCalledWith([ + firstDerivationPath, + secondDerivationPath, + ]); + expect(mockRepository.create).toHaveBeenCalledWith( + secondDerivationPath, + createParams.network, + createParams.addressType, + ); + expect(newAccount.revealNextAddress).toHaveBeenCalled(); + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: newAccount.id }, + }); + expect(result).toStrictEqual([existingAccount, newAccount]); + }); + + it('creates only one account for duplicate derivation paths in the same batch', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([null]); + mockRepository.create.mockResolvedValue(newAccount); + + const result = await useCases.createMany([createParams, createParams]); + + expect(mockRepository.getByDerivationPaths).toHaveBeenCalledWith([ + firstDerivationPath, + ]); + expect(mockRepository.create).toHaveBeenCalledTimes(1); + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(result).toStrictEqual([newAccount, newAccount]); + }); + + it('does not create or insert accounts when all accounts already exist', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([existingAccount]); + + const result = await useCases.createMany([createParams]); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.insertMany).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(result).toStrictEqual([existingAccount]); + }); + + it('propagates insertMany errors without emitting account-created events', async () => { + const error = new Error('insertMany failed'); + mockRepository.getByDerivationPaths.mockResolvedValue([null]); + mockRepository.create.mockResolvedValue(newAccount); + mockRepository.insertMany.mockRejectedValue(error); + + await expect(useCases.createMany([createParams])).rejects.toBe(error); + + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + + it('waits for in-flight creates before rejecting when one create fails', async () => { + const error = new Error('create failed'); + const slowAccount = mock({ + id: 'slow-id', + network: createParams.network, + }); + let resolveSlowCreate: (account: BitcoinAccount) => void = () => + undefined; + const slowCreate = new Promise((resolve) => { + resolveSlowCreate = resolve; + }); + const callOrder: string[] = []; + + mockRepository.getByDerivationPaths.mockResolvedValue([null, null]); + mockRepository.create + .mockImplementationOnce(async () => { + callOrder.push('create-1'); + throw error; + }) + .mockImplementationOnce(async () => { + callOrder.push('create-2'); + const account = await slowCreate; + callOrder.push('resolve-2'); + return account; + }); + + const createManyPromise = useCases.createMany([ + createParams, + { ...createParams, index: 2 }, + ]); + const onSettled = jest.fn(); + const settlementObserver = createManyPromise.then(onSettled, onSettled); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(onSettled).not.toHaveBeenCalled(); + + resolveSlowCreate(slowAccount); + + await expect(createManyPromise).rejects.toBe(error); + await settlementObserver; + expect(callOrder).toStrictEqual(['create-1', 'create-2', 'resolve-2']); + expect(mockRepository.insertMany).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + }); + + describe('discover', () => { + const discoverParams: DiscoverAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + }; + const mockAccount = mock({ + network: discoverParams.network, + }); + + beforeEach(() => { + mockRepository.create.mockResolvedValue(mockAccount); + }); + + it.each([ + { tAddressType: 'p2pkh', purpose: "44'" }, + { tAddressType: 'p2sh', purpose: "49'" }, + { tAddressType: 'p2wsh', purpose: "45'" }, + { tAddressType: 'p2wpkh', purpose: "84'" }, + { tAddressType: 'p2tr', purpose: "86'" }, + ] as { tAddressType: AddressType; purpose: string }[])( + 'discovers an account of type: %s', + async ({ tAddressType, purpose }) => { + const derivationPath = [ + discoverParams.entropySource, + purpose, + "0'", + `${discoverParams.index}'`, + ]; + + await useCases.discover({ + ...discoverParams, + addressType: tAddressType, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + derivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + derivationPath, + discoverParams.network, + tAddressType, + ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + }, + ); + + it.each([ + { tNetwork: 'bitcoin', coinType: "0'" }, + { tNetwork: 'testnet', coinType: "1'" }, + { tNetwork: 'testnet4', coinType: "1'" }, + { tNetwork: 'signet', coinType: "1'" }, + { tNetwork: 'regtest', coinType: "1'" }, + ] as { tNetwork: Network; coinType: string }[])( + 'should discover an account on network: %s', + async ({ tNetwork, coinType }) => { + const expectedDerivationPath = [ + discoverParams.entropySource, + "84'", + coinType, + `${discoverParams.index}'`, + ]; + + await useCases.discover({ + ...discoverParams, + network: tNetwork, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + expectedDerivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + expectedDerivationPath, + tNetwork, + discoverParams.addressType, + ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + }, + ); + + it('returns an existing account if one already exists on same network', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(mockAccount); + + const result = await useCases.discover(discoverParams); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockChain.fullScan).not.toHaveBeenCalled(); + + expect(result).toBe(mockAccount); + }); + + it('propagates an error if getByDerivationPath throws', async () => { + const error = new Error('getByDerivationPath failed'); + mockRepository.getByDerivationPath.mockRejectedValue(error); + + await expect(useCases.discover(discoverParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); + }); + + it('propagates an error if create throws', async () => { + const error = new Error('create failed'); + mockRepository.create.mockRejectedValue(error); + + await expect(useCases.discover(discoverParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + }); + + it('propagates an error if fullScan throws', async () => { + const error = new Error('fullScan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect(useCases.discover(discoverParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockChain.fullScan).toHaveBeenCalled(); + }); + }); + + describe('synchronize', () => { + const mockAccount = mock({ + id: 'some-id', + listTransactions: jest.fn(), + }); + + beforeEach(() => { + mockAccount.listTransactions.mockReturnValue([]); + }); + + it('synchronizes', async () => { + mockAccount.listTransactions.mockReturnValue([]); + + const result = await useCases.synchronize(mockAccount, 'test'); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [], + }); + }); + + it('synchronizes with new transactions', async () => { + const mockInscriptions = mock(); + const mockTransaction = mock(); + mockAccount.listTransactions + .mockReturnValueOnce([]) + .mockReturnValueOnce([mockTransaction]); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + + const result = await useCases.synchronize(mockAccount, 'test'); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTransaction, + 'test', + ); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTransaction], + }); + }); + + it('synchronizes with confirmed transactions', async () => { + const mockTxPending = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid', + }, + }); + const mockTxConfirmed = mock({ + ...mockTxPending, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + mockAccount.listTransactions + .mockReturnValueOnce([mockTxPending]) + .mockReturnValueOnce([mockTxConfirmed]); + + const result = await useCases.synchronize(mockAccount, 'test'); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionFinalized, + mockAccount, + mockTxConfirmed, + 'test', + ); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxConfirmed], + }); + }); + + it('synchronizes with both new and confirmed transactions', async () => { + const mockTxPending = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid1', + }, + }); + const mockTxNew = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid2', + }, + }); + const mockTxConfirmed = mock({ + ...mockTxPending, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + + const mockTxPreviouslyConfirmed = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + const mockTxReorged = mock({ + ...mockTxPreviouslyConfirmed, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + }); + + mockAccount.listTransactions + .mockReturnValueOnce([mockTxPending, mockTxPreviouslyConfirmed]) + .mockReturnValueOnce([mockTxConfirmed, mockTxNew, mockTxReorged]); + const mockInscriptions = mock(); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + const origin = 'test'; + + const result = await useCases.synchronize(mockAccount, origin); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + + // Check for TransactionFinalized event for confirmed transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionFinalized, + mockAccount, + mockTxConfirmed, + origin, + ); + + // Check for TransactionReceived event for new transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTxNew, + origin, + ); + + // Check for TransactionReorged event for reorged transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReorged, + mockAccount, + mockTxReorged, + origin, + ); + + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(3); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxConfirmed, mockTxNew, mockTxReorged], + }); + }); + + it('should emit TransactionReorged when a confirmed transaction becomes unconfirmed', async () => { + /* eslint-disable @typescript-eslint/naming-convention */ + const mockTxConfirmed = mock({ + chain_position: { is_confirmed: true }, + }); + const mockTxReorged = mock({ + ...mockTxConfirmed, + chain_position: { is_confirmed: false }, + }); + mockAccount.listTransactions + .mockReturnValueOnce([mockTxConfirmed]) + .mockReturnValueOnce([mockTxReorged]); + + const result = await useCases.synchronize(mockAccount, 'test'); + + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReorged, + mockAccount, + mockTxReorged, + 'test', + ); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxReorged], + }); + }); + + it('propagates an error if the chain sync fails', async () => { + const error = new Error('Sync failed'); + mockChain.sync.mockRejectedValue(error); + + await expect(useCases.synchronize(mockAccount, 'test')).rejects.toBe( + error, + ); + + expect(mockChain.sync).toHaveBeenCalled(); + }); + + it('propagates an error if the repository update fails', async () => { + mockChain.sync.mockResolvedValue(); + const error = new Error('Update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect(useCases.synchronize(mockAccount, 'test')).rejects.toBe( + error, + ); + + expect(mockChain.sync).toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalled(); + }); + + it('does not synchronize assets if utxo protection is disabled', async () => { + const testUseCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ); + const mockTransaction = mock(); + mockAccount.listTransactions + .mockReturnValueOnce([]) + .mockReturnValueOnce([mockTransaction]); + + await testUseCases.synchronize(mockAccount, 'test'); + + expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); + }); + + it('continues synchronization when tracking events fail', async () => { + const mockTransaction = mock(); + const mockInscriptions = mock(); + const trackingError = new Error('Tracking service unavailable'); + + mockAccount.listTransactions + .mockReturnValueOnce([]) + .mockReturnValueOnce([mockTransaction]); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + mockSnapClient.emitTrackingEvent.mockImplementation(async () => { + mockLogger.error( + `Failed to track event: ${TrackingSnapEvent.TransactionReceived}`, + trackingError, + ); + }); + + const result = await useCases.synchronize(mockAccount, 'test'); + + // core synchronization functionality should still work + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + + // tracking should have been attempted + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTransaction, + 'test', + ); + + // error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event: Transaction Received', + trackingError, + ); + + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTransaction], + }); + }); + }); + + describe('fullScan', () => { + const mockAccount = mock({ + id: 'some-id', + }); + const mockInscriptions = mock(); + const mockTransactions = mock(); + + it('performs a full scan', async () => { + mockAccount.listTransactions.mockReturnValue(mockTransactions); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + + const result = await useCases.fullScan(mockAccount); + + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: mockTransactions, + }); + }); + + it('propagates an error if the chain full scan fails', async () => { + const error = new Error('Full scan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); + + expect(mockChain.fullScan).toHaveBeenCalled(); + }); + + it('propagates an error if fetchInscriptions fails', async () => { + mockChain.fullScan.mockResolvedValue(); + const error = new Error('fetchInscriptions failed'); + mockMetaProtocols.fetchInscriptions.mockRejectedValue(error); + + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); + + expect(mockChain.fullScan).toHaveBeenCalled(); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalled(); + }); + + it('propagates an error if the repository update fails', async () => { + mockChain.fullScan.mockResolvedValue(); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + const error = new Error('Update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); + + expect(mockChain.fullScan).toHaveBeenCalled(); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalled(); + }); + + it('does not scan for assets if utxo protection is disabled', async () => { + const testUseCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ); + + await testUseCases.fullScan(mockAccount); + + expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect(useCases.delete('non-existent-id')).rejects.toThrow( + 'Account not found', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('non-existent-id'); + expect(mockSnapClient.emitAccountDeletedEvent).not.toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('removes an account', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + + mockRepository.get.mockResolvedValue(mockAccount); + + await useCases.delete(mockAccount.id); + + expect(mockRepository.get).toHaveBeenCalledWith(mockAccount.id); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockRepository.delete).toHaveBeenCalledWith(mockAccount.id); + }); + + it('propagates an error if the event emitting fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + const error = new Error('Event emit failed'); + + mockRepository.get.mockResolvedValue(mockAccount); + mockSnapClient.emitAccountDeletedEvent.mockRejectedValue(error); + + await expect(useCases.delete(mockAccount.id)).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('propagates an error if the repository fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + const error = new Error('Delete failed'); + + mockRepository.get.mockResolvedValue(mockAccount); + mockRepository.delete.mockRejectedValue(error); + + await expect(useCases.delete(mockAccount.id)).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalled(); + expect(mockRepository.delete).toHaveBeenCalled(); + }); + }); + + describe('signPsbt', () => { + const mockTxid = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString, + }); + const mockTransaction = mock({ + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockSignedPsbt = mock({ + toString: () => 'mockSignedPsbt', + }); + const mockAccount = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + sign: jest.fn(), + capabilities: [AccountCapability.SignPsbt], + }); + const mockWalletTx = mock(); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.sign.mockReturnValue(mockSignedPsbt); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.signPsbt('non-existent-id', mockPsbt, 'metamask', { + fill: false, + broadcast: false, + }), + ).rejects.toThrow('Account not found'); + }); + + it('signs a PSBT', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const { txid, psbt } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: false, + }, + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(txid).toBeUndefined(); + expect(psbt).toBe('mockSignedPsbt'); + }); + + it('signs and broadcasts a PSBT', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const { txid, psbt, canBeMalleable } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); + expect(canBeMalleable).toBe(false); + }); + + it('omits canBeMalleable when broadcast is false', async () => { + const { canBeMalleable } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { fill: false, broadcast: false }, + ); + + expect(canBeMalleable).toBeUndefined(); + }); + + it('sets canBeMalleable=true when broadcasting from a legacy P2PKH account', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.getWithSigner.mockResolvedValueOnce(legacyAccount); + + const { canBeMalleable } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { fill: false, broadcast: true }, + ); + + expect(canBeMalleable).toBe(true); + }); + + it('fills, signs and broadcasts a PSBT', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const { psbt, txid } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: true, + broadcast: true, + }, + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); + }); + + it('propagates an error if getWithSigner fails', async () => { + const error = new Error('getWithSigner failed'); + mockRepository.getWithSigner.mockRejectedValueOnce(error); + + await expect( + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: false, + }), + ).rejects.toBe(error); + }); + + it('propagates an error if broadcast fails', async () => { + const error = new Error('broadcast failed'); + mockChain.broadcast.mockRejectedValueOnce(error); + + await expect( + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: true, + }), + ).rejects.toBe(error); + }); + + it('propagates an error if update fails', async () => { + const error = new Error('update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect( + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: true, + }), + ).rejects.toBe(error); + }); + + it('continues transaction processing when tracking events fail', async () => { + const trackingError = new Error('Tracking service unavailable'); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockSnapClient.emitTrackingEvent.mockImplementation(async () => { + mockLogger.error( + `Failed to track event: ${TrackingSnapEvent.TransactionSubmitted}`, + trackingError, + ); + }); + + // Should complete successfully despite tracking failure + const { txid, psbt } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + ); + + // Core transaction functionality should work + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + + // Tracking should have been attempted + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + + // Error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event: Transaction Submitted', + trackingError, + ); + + // Transaction should still be successful + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); + }); + }); + + describe('fillPsbt', () => { + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockTemplatePsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString: () => 'base64Psbt', + }); + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + sign: jest.fn(), + isMine: () => false, + capabilities: [AccountCapability.FillPsbt], + }); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFrozenUTXOs = ['utxo1', 'utxo2']; + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.fillPsbt('non-existent-id', mockTemplatePsbt), + ).rejects.toThrow('Account not found'); + }); + + it('fills PSBT without change output', async () => { + const psbt = await useCases.fillPsbt('account-id', mockTemplatePsbt); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(mockFrozenUTXOs); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledWith( + mockOutput.value, + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockFeeRate); + expect(mockTxBuilder.untouchedOrdering).toHaveBeenCalled(); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + + expect(psbt).toBe(mockFilledPsbt); + }); + + it('fills PSBT with change output', async () => { + mockRepository.get.mockResolvedValueOnce({ + ...mockAccount, + isMine: () => true, + }); + + const psbt = await useCases.fillPsbt('account-id', mockTemplatePsbt); + + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(psbt).toBe(mockFilledPsbt); + }); + + it('propagates an error if get fails', async () => { + const error = new Error('get failed'); + mockRepository.get.mockRejectedValueOnce(error); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toBe(error); + }); + + it('throws ValidationError if tx building fails', async () => { + const error = new Error('builder error'); + mockTxBuilder.finish.mockImplementation(() => { + throw error; + }); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toThrow( + new ValidationError( + 'Failed to build PSBT from template: builder error', + { + id: 'account-id', + templatePsbt: 'base64Psbt', + feeRate: mockFeeRate, + }, + error, + ), + ); + }); + + it('includes non-Error cause in ValidationError message', async () => { + mockTxBuilder.finish.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { message: 'InsufficientFunds', code: 11 }; + }); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toThrow( + 'Failed to build PSBT from template: InsufficientFunds', + ); + }); + + it('uses unknown cause when error has no message', async () => { + mockTxBuilder.finish.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 42; + }); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toThrow('Failed to build PSBT from template: unknown cause'); + }); + + it('preserves all outputs from bridge PSBT template including change', async () => { + // Simulate a bridge transaction with 3 outputs: + // Output 0: Bridge deposit + // Output 1: OP_RETURN data + // Output 2: Change output + const bridgeDepositOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const opReturnOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const changeOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + + const bridgePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + toString: () => 'bridgePsbtBase64', + }); + + // Mock the built PSBT to have matching output count (3 outputs) + // This simulates the happy path where drainToByScript works correctly + const builtBridgePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + }); + mockTxBuilder.finish.mockReturnValue(builtBridgePsbt); + + // Account recognizes the change output as its own + const accountWithChange = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: (script: ScriptBuf) => script === changeOutput.script_pubkey, + capabilities: [AccountCapability.FillPsbt], + }); + accountWithChange.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.get.mockResolvedValueOnce(accountWithChange); + + await useCases.fillPsbt('account-id', bridgePsbt); + + // Verify exact call counts: + // - drainToByScript: called exactly 1 time (for change output) + // - addRecipientByScript: called exactly 2 times (for bridge deposit and OP_RETURN) + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledTimes(1); + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + changeOutput.script_pubkey, + ); + + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledTimes(2); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 1, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 2, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + + // finish() should only be called once since output count matches + expect(mockTxBuilder.finish).toHaveBeenCalledTimes(1); + }); + + it('rebuilds with fixed recipients when change output is dropped (full-balance swap)', async () => { + // This test specifically covers the case where change was dropped + // when user swapped their entire balance through a bridge + // Simulate a bridge transaction with 3 outputs: + // Output 0: Bridge deposit + // Output 1: OP_RETURN data + // Output 2: Change output + const bridgeDepositOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const opReturnOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const changeOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + + const fullBalanceSwapPsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + toString: () => 'fullBalanceSwapPsbt', + }); + + // First build returns PSBT with dropped change + const droppedChangePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput], // Only 2 outputs - change is dropped + }, + }); + // Second build (rebuild) returns PSBT with all outputs preserved + const rebuiltPsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], // All 3 outputs preserved + }, + }); + mockTxBuilder.finish + .mockReturnValueOnce(droppedChangePsbt) // First attempt: change dropped + .mockReturnValueOnce(rebuiltPsbt); // Second attempt: rebuilt with fixed recipients + + // Account recognizes the change output as its own + const accountFullBalance = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: (script: ScriptBuf) => script === changeOutput.script_pubkey, + capabilities: [AccountCapability.FillPsbt], + }); + accountFullBalance.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.get.mockResolvedValueOnce(accountFullBalance); + + const result = await useCases.fillPsbt('account-id', fullBalanceSwapPsbt); + + // finish() called twice: first attempt (change dropped) + rebuild + expect(mockTxBuilder.finish).toHaveBeenCalledTimes(2); + + // drainToByScript: called exactly 1 time (for change output in first attempt) + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledTimes(1); + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + changeOutput.script_pubkey, + ); + + // addRecipientByScript call count: + // First attempt: 2 calls (bridge deposit + OP_RETURN) + // Second attempt (rebuild): 3 calls (all outputs as fixed recipients) + // Total: 2 + 3 = 5 + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledTimes(5); + + // First attempt: bridge deposit and OP_RETURN + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 1, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 2, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + + // Second attempt (rebuild): all 3 outputs as fixed recipients + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 3, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 4, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 5, + changeOutput.value, + changeOutput.script_pubkey, + ); + + // Result should be the rebuilt PSBT with all outputs preserved + expect(result).toBe(rebuiltPsbt); + }); + }); + + describe('computeFee', () => { + const mockFee = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockTemplatePsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString: () => 'base64Psbt', + }); + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: () => false, + capabilities: [AccountCapability.ComputeFee], + }); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFrozenUTXOs = ['utxo1', 'utxo2']; + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockFilledPsbt.fee.mockReturnValue(mockFee); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.computeFee('account-id', mockTemplatePsbt), + ).rejects.toThrow('Account not found'); + }); + + it('computes fee for PSBT without change output', async () => { + const fee = await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith('bitcoin'); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(mockFrozenUTXOs); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledWith( + mockOutput.value, + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockFeeRate); + expect(mockTxBuilder.untouchedOrdering).toHaveBeenCalled(); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + expect(fee).toBe(mockFee); + }); + + it('computes fee for PSBT with change output', async () => { + mockRepository.get.mockResolvedValueOnce({ + ...mockAccount, + isMine: () => true, + }); + + const fee = await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).not.toHaveBeenCalled(); + expect(fee).toBe(mockFee); + }); + + it('uses fallback fee rate when estimate is not available', async () => { + mockChain.getFeeEstimates.mockResolvedValueOnce({ + ...mockFeeEstimates, + get: () => undefined, + }); + + await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(fallbackFeeRate); + }); + }); + + describe('sendTransfer', () => { + const recipients = [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ]; + const mockTxid = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString, + }); + const mockTransaction = mock({ + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockSignedPsbt = mock({ + toString: () => 'mockSignedPsbt', + }); + const mockAccount = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + sign: jest.fn(), + capabilities: [AccountCapability.SendTransfer], + }); + const mockWalletTx = mock(); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); + const mockTxBuilder = mock({ + addRecipient: jest.fn(), + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.sign.mockReturnValue(mockSignedPsbt); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockRepository.getFrozenUTXOs.mockResolvedValue([]); + }); + + it('throws error if there are multiple recipients', async () => { + const multipleRecipients = [ + { address: 'addr1', amount: '1000' }, + { address: 'addr2', amount: '2000' }, + ]; + + await expect( + useCases.sendTransfer('account-id', multipleRecipients, 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); + }); + + it('throws error if there are no recipients', async () => { + await expect( + useCases.sendTransfer('account-id', [], 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.sendTransfer('non-existent-id', recipients, 'metamask'), + ).rejects.toThrow('Account not found'); + }); + + it('sends funds', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const result = await useCases.sendTransfer( + 'account-id', + recipients, + 'metamask', + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '1000', + 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(false); + }); + + it('sets canBeMalleable=true on legacy P2PKH accounts', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.getWithSigner.mockResolvedValueOnce(legacyAccount); + + const result = await useCases.sendTransfer( + 'account-id', + recipients, + 'metamask', + ); + + expect(result.canBeMalleable).toBe(true); + }); + + it('propagates an error if getWithSigner fails', async () => { + const error = new Error('getWithSigner failed'); + mockRepository.getWithSigner.mockRejectedValueOnce(error); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + + it('propagates an error if broadcast fails', async () => { + const error = new Error('broadcast failed'); + mockChain.broadcast.mockRejectedValueOnce(error); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + + it('propagates an error if update fails', async () => { + const error = new Error('update failed'); + mockRepository.update.mockRejectedValue(error); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + }); + + describe('broadcastPsbt', () => { + const mockTxid = mock(); + const mockPsbt = mock(); + const mockTransaction = mock({ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockWalletTx = mock(); + const mockAccount = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + capabilities: [AccountCapability.BroadcastPsbt], + }); + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.broadcastPsbt('non-existent-id', mockPsbt, 'metamask'), + ).rejects.toThrow('Account not found'); + }); + + it('throws PermissionError if account lacks BroadcastPsbt capability', async () => { + const accountNoCap = mock({ + capabilities: [], + }); + mockRepository.get.mockResolvedValue(accountNoCap); + + await expect( + useCases.broadcastPsbt('account-id', mockPsbt, 'metamask'), + ).rejects.toThrow('Account missing given capability'); + }); + + it('broadcasts a PSBT and returns txid with canBeMalleable=false for P2WPKH', async () => { + const result = await useCases.broadcastPsbt( + 'account-id', + mockPsbt, + 'metamask', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccount.extractTransaction).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(false); + }); + + it('returns canBeMalleable=true when broadcasting from a legacy P2PKH account', async () => { + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.get.mockResolvedValueOnce(legacyAccount); + + const result = await useCases.broadcastPsbt( + 'account-id', + mockPsbt, + 'metamask', + ); + + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(true); + }); + }); + + describe('getFrozenUTXOs', () => { + it('delegates to repository', async () => { + mockRepository.getFrozenUTXOs.mockResolvedValue(['txid:0', 'txid:1']); + + const result = await useCases.getFrozenUTXOs('account-id'); + + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual(['txid:0', 'txid:1']); + }); + }); + + describe('signMessage', () => { + const mockAccount = mock({ + publicAddress: mock
({ + toString: () => 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + }), + capabilities: [AccountCapability.SignMessage], + derivationPath: ['m', "84'", "0'"], + }); + const mockMessage = 'Hello, world!'; + const mockOrigin = 'metamask'; + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockSnapClient.getPrivateEntropy.mockResolvedValue({ + privateKey: + '0xdf23b869a1395aec3bf878797daac998ed4acf404fa26ff622eef7f30dc46791', + } as JsonSLIP10Node); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.signMessage('non-existent-id', mockMessage, mockOrigin), + ).rejects.toThrow('Account not found'); + }); + + it('signs a message', async () => { + const expectedSignature = + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4='; + + const signature = await useCases.signMessage( + 'account-id', + mockMessage, + mockOrigin, + ); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith([ + 'm', + "84'", + "0'", + '0', + '0', + ]); + expect(mockConfirmationRepository.insertSignMessage).toHaveBeenCalledWith( + mockAccount, + mockMessage, + mockOrigin, + ); + expect(signature).toBe(expectedSignature); + }); + + it('throws WalletError if fails to sign message', async () => { + mockSnapClient.getPrivateEntropy.mockResolvedValue({ + privateKey: '0x1234567890abcdef', // wrong private key returned + } as JsonSLIP10Node); + + await expect( + useCases.signMessage('account-id', mockMessage, mockOrigin), + ).rejects.toThrow('Failed to sign message'); + }); + + it('throws AssertionError if entropy has no privateKey', async () => { + mockSnapClient.getPrivateEntropy.mockResolvedValue( + mock({ privateKey: undefined }), + ); + + await expect( + useCases.signMessage('account-id', mockMessage, mockOrigin), + ).rejects.toThrow('Failed to get private entropy'); + }); + + it('propagates an error if get fails', async () => { + const error = new Error('get failed'); + mockRepository.get.mockRejectedValueOnce(error); + + await expect( + useCases.signMessage('account-id', mockMessage, mockOrigin), + ).rejects.toBe(error); + }); + + it('propagates an error if getPrivateEntropy fails', async () => { + const error = new Error('getPrivateEntropy failed'); + mockSnapClient.getPrivateEntropy.mockRejectedValue(error); + + await expect( + useCases.signMessage('account-id', mockMessage, mockOrigin), + ).rejects.toBe(error); + }); + + it('skips confirmation when skipConfirmation option is true', async () => { + await useCases.signMessage('account-id', mockMessage, mockOrigin, { + skipConfirmation: true, + }); + + expect( + mockConfirmationRepository.insertSignMessage, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts new file mode 100644 index 00000000..ae31ecd8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -0,0 +1,900 @@ +import type { + AddressType, + Amount, + Network, + Psbt, + Transaction, + Txid, + WalletTx, +} from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import { Signer } from 'bip322-js'; +import { encode } from 'wif'; + +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + ConfirmationRepository, + Logger, + MetaProtocolsClient, + SnapClient, + SyncResult, +} from '../entities'; +import { + AccountCapability, + addressTypeToPurpose, + AssertionError, + canAccountTxidBeMalleated, + networkToCoinType, + NotFoundError, + PermissionError, + TrackingSnapEvent, + ValidationError, + WalletError, +} from '../entities'; +import { CronMethod } from '../handlers/CronHandler'; + +export type DiscoverAccountParams = { + network: Network; + index: number; + entropySource: string; + addressType: AddressType; +}; + +export type CreateAccountParams = DiscoverAccountParams & { + synchronize: boolean; + correlationId?: string; + accountName?: string; +}; + +// Snap entropy derivation can become very spiky under wider parallelism. +const CREATE_ACCOUNTS_CONCURRENCY = 2; + +/** + * @param req - Account creation or discovery request. + * @returns The BIP-44 account derivation path. + */ +function getAccountDerivationPath(req: DiscoverAccountParams): string[] { + return [ + req.entropySource, + `${addressTypeToPurpose[req.addressType]}'`, + `${networkToCoinType[req.network]}'`, + `${req.index}'`, + ]; +} + +/** + * @param derivationPath - Split derivation path. + * @returns Storage key for a derivation path. + */ +function getDerivationPathKey(derivationPath: string[]): string { + return derivationPath.join('/'); +} + +/** + * Map items to results with at most `concurrency` in-flight async operations. + * Output order matches `items` order. + * + * @param items - Values to map in pool order. + * @param concurrency - Maximum number of concurrent mapper executions. + * @param mapper - Async function applied to each item. + * @returns Results in the same order as `items`. + */ +async function runWithConcurrencyLimit( + items: readonly Item[], + concurrency: number, + mapper: (item: Item, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const results: Result[] = new Array(items.length); + let next = 0; + let firstError: unknown; + let hasError = false; + + const worker = async (): Promise => { + while (!hasError) { + const idx = next; + next += 1; + if (idx >= items.length) { + return; + } + + try { + results[idx] = await mapper(items[idx] as Item, idx); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + return; + } + } + }; + + const poolSize = Math.min(Math.max(1, concurrency), items.length); + await Promise.all(Array.from({ length: poolSize }, async () => worker())); + + if (hasError) { + throw firstError; + } + + return results; +} + +/** + * Result of broadcasting a Bitcoin transaction. + * + * `canBeMalleable` is `true` when the source account's address type allows a + * third party to rewrite the txid before confirmation (currently only legacy + * P2PKH). Consumers that persist or surface the txid to the dApp should treat + * a `true` flag as a signal that the txid may change, and avoid trusting it + * for finality decisions before block confirmation. + * + * Limitation: the flag is computed from the snap account's address type, which + * is correct for transactions the snap builds itself (`sendTransfer`, + * `executeSendFlow`, `confirmSend`). For externally-provided PSBTs broadcast + * via `broadcastPsbt` or `signPsbt({ broadcast: true, fill: false })`, the + * transaction may include foreign inputs of any address type; if any foreign + * input is legacy non-witness, the txid is malleable even when the snap's + * account is P2WPKH. Callers building collaborative transactions must compute + * malleability per-input. + */ +export type BroadcastResult = { + txid: Txid; + canBeMalleable: boolean; +}; + +export class AccountUseCases { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + readonly #repository: BitcoinAccountRepository; + + readonly #confirmationRepository: ConfirmationRepository; + + readonly #chain: BlockchainClient; + + readonly #metaProtocols: MetaProtocolsClient | undefined; + + readonly #fallbackFeeRate: number; + + readonly #targetBlocksConfirmation: number; + + #accountMutationQueue: Promise = Promise.resolve(); + + constructor( + logger: Logger, + snapClient: SnapClient, + repository: BitcoinAccountRepository, + confirmationRepository: ConfirmationRepository, + chain: BlockchainClient, + fallbackFeeRate: number, + targetBlocksConfirmation: number, + metaProtocols?: MetaProtocolsClient, + ) { + this.#logger = logger; + this.#snapClient = snapClient; + this.#repository = repository; + this.#confirmationRepository = confirmationRepository; + this.#chain = chain; + this.#fallbackFeeRate = fallbackFeeRate; + this.#targetBlocksConfirmation = targetBlocksConfirmation; + this.#metaProtocols = metaProtocols; + } + + async list(): Promise { + this.#logger.debug('Listing accounts'); + + const accounts = await this.#repository.getAll(); + + this.#logger.debug('Accounts listed successfully'); + return accounts; + } + + async get(id: string): Promise { + this.#logger.debug('Fetching account: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + + this.#logger.debug('Account found: %s', account.id); + return account; + } + + async discover(req: DiscoverAccountParams): Promise { + this.#logger.debug('Discovering Bitcoin account. Request: %o', req); + + const { addressType, network } = req; + const derivationPath = getAccountDerivationPath(req); + + // Idempotent account creation + ensures only one account per derivation path + const account = await this.#repository.getByDerivationPath(derivationPath); + if (account && account.network === network) { + this.#logger.debug('Account already exists: %s,', account.id); + return account; + } + + const newAccount = await this.#repository.create( + derivationPath, + network, + addressType, + ); + + // We need to do a full scan here to know if the account + // has any previous activity since later on we filter out + // accounts with no tx history + await this.#chain.fullScan(newAccount); + + this.#logger.info( + 'Bitcoin account discovered successfully. Request: %o', + req, + ); + return newAccount; + } + + async create(req: CreateAccountParams): Promise { + this.#logger.debug('Creating new Bitcoin account. Request: %o', req); + + return this.#runAccountMutation(async () => { + const { addressType, network, correlationId, accountName, synchronize } = + req; + const derivationPath = getAccountDerivationPath(req); + + // Idempotent account creation + ensures only one account per derivation path + const account = + await this.#repository.getByDerivationPath(derivationPath); + if (account && account.network === network) { + this.#logger.debug('Account already exists: %s,', account.id); + await this.#snapClient.emitAccountCreatedEvent( + account, + correlationId, + accountName, + ); + return account; + } + + const newAccount = await this.#repository.create( + derivationPath, + network, + addressType, + ); + + newAccount.revealNextAddress(); + + await this.#repository.insert(newAccount); + + // First notify the event has been created, then schedule full scan. + await this.#snapClient.emitAccountCreatedEvent( + newAccount, + correlationId, + accountName, + ); + + if (synchronize) { + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: newAccount.id }, + }); + } + + this.#logger.info( + 'Bitcoin account created successfully: %s. Public address: %s, Request: %o', + newAccount.id, + newAccount.publicAddress, + req, + ); + return newAccount; + }); + } + + async createMany(reqs: CreateAccountParams[]): Promise { + if (reqs.length === 0) { + return []; + } + + const { accounts, createdAccountKeys } = await this.#runAccountMutation( + async () => { + const entries = reqs.map((req, index) => { + const derivationPath = getAccountDerivationPath(req); + return { + req, + index, + derivationPath, + pathKey: getDerivationPathKey(derivationPath), + }; + }); + + const uniqueEntriesByPath = new Map(); + for (const entry of entries) { + if (!uniqueEntriesByPath.has(entry.pathKey)) { + uniqueEntriesByPath.set(entry.pathKey, entry); + } + } + const uniqueEntries = [...uniqueEntriesByPath.values()]; + + const existingAccounts = await this.#repository.getByDerivationPaths( + uniqueEntries.map(({ derivationPath }) => derivationPath), + ); + const existingAccountsByPath = new Map(); + + uniqueEntries.forEach((entry, index) => { + const account = existingAccounts[index]; + if (account && account.network === entry.req.network) { + existingAccountsByPath.set(entry.pathKey, account); + } + }); + + const entriesToCreate = uniqueEntries.filter( + ({ pathKey }) => !existingAccountsByPath.has(pathKey), + ); + const newAccounts = await runWithConcurrencyLimit( + entriesToCreate, + CREATE_ACCOUNTS_CONCURRENCY, + async ({ derivationPath, req }) => { + const newAccount = await this.#repository.create( + derivationPath, + req.network, + req.addressType, + ); + newAccount.revealNextAddress(); + return newAccount; + }, + ); + + if (newAccounts.length > 0) { + await this.#repository.insertMany(newAccounts); + } + + const newAccountsByPath = new Map( + entriesToCreate.map((entry, index) => [ + entry.pathKey, + newAccounts[index] as BitcoinAccount, + ]), + ); + + const accountsInOrder = entries.map((entry) => { + const account = + existingAccountsByPath.get(entry.pathKey) ?? + newAccountsByPath.get(entry.pathKey); + + if (!account) { + throw new AssertionError('Failed to create account', { + index: entry.index, + derivationPath: entry.derivationPath, + }); + } + + return account; + }); + + return { + accounts: accountsInOrder, + createdAccountKeys: new Set(newAccountsByPath.keys()), + }; + }, + ); + + const scheduledAccountIds = new Set(); + for (const [index, account] of accounts.entries()) { + const req = reqs[index] as CreateAccountParams; + const pathKey = getDerivationPathKey(getAccountDerivationPath(req)); + if ( + req.synchronize && + createdAccountKeys.has(pathKey) && + !scheduledAccountIds.has(account.id) + ) { + scheduledAccountIds.add(account.id); + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: account.id }, + }); + } + } + + return accounts; + } + + async synchronize( + account: BitcoinAccount, + origin: string, + ): Promise { + this.#logger.debug('Synchronizing account: %s', account.id); + + const txsBeforeSync = account.listTransactions(); + await this.#chain.sync(account); + const txsAfterSync = account.listTransactions(); + + // If new transactions appeared, fetch inscriptions; otherwise, just update. + if (txsAfterSync.length > txsBeforeSync.length) { + const inscriptions = this.#metaProtocols + ? await this.#metaProtocols.fetchInscriptions(account) + : []; + await this.#repository.update(account, inscriptions); + } else { + await this.#repository.update(account); + } + + // Create a map for quick lookup of transactions before sync + const txMapBefore = new Map(); + for (const tx of txsBeforeSync) { + txMapBefore.set(tx.txid.toString(), tx); + } + + const txsToNotify: WalletTx[] = []; + + for (const tx of txsAfterSync) { + const prevTx = txMapBefore.get(tx.txid.toString()); + + if (!prevTx) { + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + origin, + ); + + continue; + } + + const prevConfirmed = prevTx.chain_position.is_confirmed; + const currConfirmed = tx.chain_position.is_confirmed; + + const statusChanged = + (prevConfirmed && !currConfirmed) || (!prevConfirmed && currConfirmed); + + if (statusChanged) { + if (tx.chain_position.is_confirmed) { + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionFinalized, + account, + tx, + origin, + ); + } else { + // if the status was changed, and now it's NOT confirmed + // it means the tx was reorged. + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReorged, + account, + tx, + origin, + ); + } + } + } + + this.#logger.debug('Account synchronized successfully: %s', account.id); + + return { + account, + transactionsToNotify: txsToNotify, + }; + } + + async fullScan(account: BitcoinAccount): Promise { + this.#logger.debug('Performing initial full scan: %s', account.id); + + await this.#chain.fullScan(account); + + const inscriptions = this.#metaProtocols + ? await this.#metaProtocols.fetchInscriptions(account) + : []; + await this.#repository.update(account, inscriptions); + + this.#logger.info( + 'initial full scan performed successfully: %s', + account.id, + ); + + return { + account, + transactionsToNotify: account.listTransactions(), + }; + } + + async delete(id: string): Promise { + this.#logger.debug('Deleting account: %s', id); + + await this.#runAccountMutation(async () => { + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + + await this.#snapClient.emitAccountDeletedEvent(id); + await this.#repository.delete(id); + + this.#logger.info('Account deleted successfully: %s', account.id); + }); + } + + async fillPsbt( + id: string, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { + this.#logger.debug('Filling PSBT inputs: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.FillPsbt); + + const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); + + this.#logger.info( + 'PSBT filled successfully: %s. Account: %s, Network: %s', + account.id, + account.network, + ); + + return psbt; + } + + async signPsbt( + id: string, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + feeRate?: number, + ): Promise<{ psbt: string; txid?: Txid; canBeMalleable?: boolean }> { + this.#logger.debug('Signing PSBT: %s', id, options); + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.SignPsbt); + + const psbtToSign = options.fill + ? await this.#fillPsbt(account, psbt, feeRate) + : psbt; + const signedPsbt = account.sign(psbtToSign); + + if (options.broadcast) { + const psbtString = signedPsbt.toString(); + const tx = account.extractTransaction(signedPsbt); + const { txid, canBeMalleable } = await this.#broadcast( + account, + tx, + origin, + ); + + this.#logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s, Options: %o', + txid.toString(), + account.id, + account.network, + options, + ); + return { psbt: psbtString, txid, canBeMalleable }; + } + + this.#logger.info( + 'PSBT signed successfully. Account: %s, Network: %s, Options: %o', + account.id, + account.network, + options, + ); + return { psbt: signedPsbt.toString() }; + } + + async computeFee( + id: string, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { + this.#logger.debug('Getting fee amount for Psbt for account id: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.ComputeFee); + + const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); + return psbt.fee(); + } + + async broadcastPsbt( + id: string, + psbt: Psbt, + origin: string, + ): Promise { + this.#logger.debug('Sending transaction: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.BroadcastPsbt); + + const tx = account.extractTransaction(psbt); + const result = await this.#broadcast(account, tx, origin); + + this.#logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s', + result.txid, + account.id, + account.network, + ); + + return result; + } + + async sendTransfer( + id: string, + recipients: { address: string; amount: string }[], + origin: string, + feeRate?: number, + ): Promise { + this.#logger.debug( + 'Transferring funds: %s. Recipients: %o', + id, + recipients, + ); + + if (!recipients[0] || recipients.length > 1) { + throw new ValidationError('There should be exactly one recipient', { + recipients, + }); + } + const recipient = recipients[0]; + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.SendTransfer); + + const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); + const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); + + // Build PSBT with the recipient as output + const psbt = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .addRecipient(recipient.amount, recipient.address) + .finish(); + + await this.#confirmationRepository.insertSendTransfer( + account, + psbt, + recipient, + origin, + ); + + const signedPsbt = account.sign(psbt); + const tx = account.extractTransaction(signedPsbt); + const result = await this.#broadcast(account, tx, origin); + + this.#logger.info( + 'Funds transferred successfully: %s. Account: %s, Network: %s', + result.txid.toString(), + account.id, + account.network, + ); + return result; + } + + async signMessage( + id: string, + message: string, + origin: string, + options: { skipConfirmation?: boolean } = {}, + ): Promise { + this.#logger.debug('Signing message: %s. Message: %s', id, message); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.SignMessage); + + const { skipConfirmation = false } = options; + if (!skipConfirmation) { + await this.#confirmationRepository.insertSignMessage( + account, + message, + origin, + ); + } + + const entropy = await this.#snapClient.getPrivateEntropy( + account.derivationPath.concat(['0', '0']), // We sign with address index 0, which is the public address + ); + if (!entropy.privateKey) { + // Should never happen when getting the private entropy + throw new AssertionError('Failed to get private entropy', { + id, + }); + } + + try { + // Private key is returned in "0x..." format, transform into WIF: + const wifPrivateKey = encode({ + version: account.network === 'bitcoin' ? 128 : 239, // 128 for mainnet, 239 for testnets + // eslint-disable-next-line no-restricted-globals + privateKey: Buffer.from(entropy.privateKey.slice(2), 'hex'), + compressed: true, + }); + const signature = Signer.sign( + wifPrivateKey, + account.publicAddress.toString(), + message, + ); + + this.#logger.info( + 'Message signed successfully: %s. Message: %s, Signature: %s.', + id, + message, + signature, + ); + return signature; + } catch (error) { + throw new WalletError( + 'Failed to sign message', + { + id, + message, + }, + error, + ); + } + } + + async getFrozenUTXOs(accountId: string): Promise { + return this.#repository.getFrozenUTXOs(accountId); + } + + async getFallbackFeeRate(account: BitcoinAccount): Promise { + const feeEstimates = await this.#chain.getFeeEstimates(account.network); + return ( + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate + ); + } + + async #fillPsbt( + account: BitcoinAccount, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { + const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); + const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); + + try { + let builder = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .untouchedOrdering(); // we need to strictly adhere to the template output order. Many protocols use the order (e.g: 1: deposit, 2: OP_RETURN, 3: change) + + for (const txout of templatePsbt.unsigned_tx.output) { + // if the PSBT contains an output that is sending to ourselves, we change its value. If the PSBT contains no change outputs, one will automatically be added. + if (account.isMine(txout.script_pubkey)) { + builder = builder.drainToByScript(txout.script_pubkey); + } else { + builder = builder.addRecipientByScript( + txout.value, + txout.script_pubkey, + ); + } + } + let builtPsbt = builder.finish(); + + if ( + builtPsbt.unsigned_tx.output.length < + templatePsbt.unsigned_tx.output.length + ) { + // Second attempt: use fixed recipients for all outputs + builder = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .untouchedOrdering(); + + for (const txout of templatePsbt.unsigned_tx.output) { + builder = builder.addRecipientByScript( + txout.value, + txout.script_pubkey, + ); + } + builtPsbt = builder.finish(); + } + + return builtPsbt; + } catch (error) { + const causeMessage = (error as Error)?.message ?? 'unknown cause'; + throw new ValidationError( + `Failed to build PSBT from template: ${causeMessage}`, + { + id: account.id, + templatePsbt: templatePsbt.toString(), + feeRate: feeRateToUse, + }, + error, + ); + } + } + + async #broadcast( + account: BitcoinAccount, + tx: Transaction, + origin: string, + ): Promise { + const txid = tx.compute_txid(); + await this.#chain.broadcast(account.network, tx.clone()); + account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); + await this.#repository.update(account); + + await this.#snapClient.emitAccountBalancesUpdatedEvent([account]); + + const walletTx = account.getTransaction(txid.toString()); + if (walletTx) { + // should always be true by assertion but needed for type checking + await this.#snapClient.emitAccountTransactionsUpdatedEvent(account, [ + walletTx, + ]); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionSubmitted, + account, + walletTx, + origin, + ); + } + + return { + txid, + canBeMalleable: canAccountTxidBeMalleated(account.addressType), + }; + } + + async #runAccountMutation( + fn: () => Promise, + ): Promise { + const previousMutation = this.#accountMutationQueue; + let releaseMutation: () => void = () => undefined; + this.#accountMutationQueue = new Promise((resolve) => { + releaseMutation = resolve; + }); + + await previousMutation; + + try { + return await fn(); + } finally { + releaseMutation(); + } + } + + #checkCapability( + account: BitcoinAccount, + capability: AccountCapability, + ): void { + if (!account.capabilities.includes(capability)) { + throw new PermissionError('Account missing given capability', { + id: account.id, + capability, + capabilities: account.capabilities, + }); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts new file mode 100644 index 00000000..7b5a4e85 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -0,0 +1,201 @@ +import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { + AssetRatesClient, + Logger, + SnapClient, + SpotPrice, +} from '../entities'; +import { AssetsUseCases } from './AssetsUseCases'; +import { Caip19Asset } from '../handlers/caip'; +import type { ICache, Serializable } from '../store/ICache'; + +describe('AssetsUseCases', () => { + const mockLogger = mock(); + const mockAssetRates = mock(); + const mockCache = mock>(); + const mockSnapClient = mock(); + + const useCases = new AssetsUseCases( + mockLogger, + mockAssetRates, + mockCache, + mockSnapClient, + ); + + describe('getBtcRates', () => { + it('returns rate for the known assets and null for unknown', async () => { + const mockExchangeRatesUSD = mock({ + price: 1, + marketData: { + allTimeHigh: '110000', + }, + }); + const mockExchangeRatesETH = mock({ + price: 1, + marketData: { + allTimeHigh: '0.1', + }, + }); + const mockExchangeRatesBTC = mock({ + price: 1, + marketData: { + allTimeHigh: '1', + }, + }); + + mockCache.get.mockResolvedValue(undefined); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesETH); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesBTC); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesUSD); + + const result = await useCases.getRates([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'swift:0/iso4217:USD', + 'swift:0/unknown:unknown', + ]); + + expect(mockAssetRates.spotPrices).toHaveBeenCalled(); + expect(result).toStrictEqual([ + ['eip155:1/slip44:60', mockExchangeRatesETH], + [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + mockExchangeRatesBTC, + ], + ['swift:0/iso4217:USD', mockExchangeRatesUSD], + ['swift:0/unknown:unknown', null], + ]); + }); + + it('returns null for assets when spotPrices fails', async () => { + const error = new Error('getRates failed'); + mockCache.get.mockResolvedValue(undefined); + mockAssetRates.spotPrices.mockRejectedValue(error); + + const result = await useCases.getRates([Caip19Asset.Testnet]); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(1); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch spot price for ticker btc', + error, + ); + expect(result).toStrictEqual([[Caip19Asset.Testnet, null]]); + }); + + it('uses cached values when available', async () => { + const cachedSpotPrice = mock({ + price: 42000, + marketData: { + allTimeHigh: '110000', + }, + }); + + mockCache.get.mockResolvedValue(cachedSpotPrice); + + const result = await useCases.getRates(['swift:0/iso4217:USD']); + + expect(mockCache.get).toHaveBeenCalledWith('spotPrices:usd'); + expect(mockAssetRates.spotPrices).not.toHaveBeenCalled(); + expect(result).toStrictEqual([['swift:0/iso4217:USD', cachedSpotPrice]]); + }); + + it('caches fetched spot prices with 30 second TTL', async () => { + const mockSpotPrice = mock({ + price: 50000, + marketData: { + allTimeHigh: '110000', + }, + }); + + mockCache.get.mockResolvedValue(undefined); + mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); + + await useCases.getRates(['swift:0/iso4217:USD']); + + expect(mockCache.set).toHaveBeenCalledWith( + 'spotPrices:usd', + mockSpotPrice, + 30000, + ); + }); + + it('deduplicates requests for assets with the same ticker', async () => { + const mockSpotPrice = mock({ + price: 50000, + marketData: { + allTimeHigh: '110000', + }, + }); + + // First call to cache.get returns undefined (cache miss) + // Subsequent calls return the cached value (cache hit) + mockCache.get + .mockResolvedValueOnce(undefined) + .mockResolvedValue(mockSpotPrice); + mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); + + // Multiple assets that map to the same ticker (usd) + const result = await useCases.getRates([ + 'swift:0/iso4217:USD', + 'swift:1/iso4217:USD', + 'swift:2/iso4217:USD', + ]); + + // Should only call spotPrices once for the unique ticker + expect(mockAssetRates.spotPrices).toHaveBeenCalledTimes(1); + expect(mockAssetRates.spotPrices).toHaveBeenCalledWith('usd'); + + // All assets should get the same spot price + expect(result).toStrictEqual([ + ['swift:0/iso4217:USD', mockSpotPrice], + ['swift:1/iso4217:USD', mockSpotPrice], + ['swift:2/iso4217:USD', mockSpotPrice], + ]); + }); + }); + + describe('getPriceIntervals', () => { + it('returns prices against the specified token', async () => { + const mockHistoricalPrices = mock(); + mockAssetRates.historicalPrices.mockResolvedValue(mockHistoricalPrices); + + const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + + expect(mockAssetRates.historicalPrices).toHaveBeenCalledTimes(6); + expect(result).toStrictEqual({ + P1D: mockHistoricalPrices, + P7D: mockHistoricalPrices, + P1M: mockHistoricalPrices, + P3M: mockHistoricalPrices, + P1Y: mockHistoricalPrices, + P1000Y: mockHistoricalPrices, + }); + }); + + it('returns empty arrays for periods when historicalPrices fails', async () => { + const error = new Error('historicalPrices failed'); + mockAssetRates.historicalPrices.mockRejectedValue(error); + + const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(6); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockLogger.warn).toHaveBeenCalledTimes(6); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch historical prices for period P1D', + error, + ); + expect(result).toStrictEqual({ + P1D: [], + P7D: [], + P1M: [], + P3M: [], + P1Y: [], + P1000Y: [], + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts new file mode 100644 index 00000000..0c5e12f9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -0,0 +1,171 @@ +import slip44 from '@metamask/slip44'; +import type { HistoricalPriceIntervals } from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { + AssetRate, + AssetRatesClient, + Logger, + SnapClient, + SpotPrice, + TimePeriod, +} from '../entities'; +import type { ICache, Serializable } from '../store/ICache'; + +export class AssetsUseCases { + readonly #logger: Logger; + + readonly #assetRates: AssetRatesClient; + + readonly #cache: ICache; + + readonly #snapClient: SnapClient; + + constructor( + logger: Logger, + assetRates: AssetRatesClient, + cache: ICache, + snapClient: SnapClient, + ) { + this.#logger = logger; + this.#assetRates = assetRates; + this.#cache = cache; + this.#snapClient = snapClient; + } + + async getRates(assets: CaipAssetType[]): Promise { + this.#logger.debug('Fetching BTC rates for: %o', assets); + + // group assets by ticker to deduplicate API calls. Multiple CAIP asset types + // can map to the same ticker (e.g., 'bip122:000000000019d6689c085ae165831e93/slip44:0' + // and other BTC representations both resolve to 'btc'), so we fetch each ticker only once. + const tickerToAssets = new Map(); + const assetsWithoutTicker: CaipAssetType[] = []; + + for (const asset of assets) { + const ticker = this.#assetToTicker(asset); + if (!ticker) { + assetsWithoutTicker.push(asset); + continue; + } + + const existing = tickerToAssets.get(ticker); + if (existing) { + existing.push(asset); + } else { + tickerToAssets.set(ticker, [asset]); + } + } + + // fetch all unique tickers in parallel. Each promise handles + // its own errors via .catch() to prevent one failure from breaking all fetches. + const promises = Array.from(tickerToAssets.entries()).map( + async ([ticker, tickerAssets]) => { + const cacheKey = `spotPrices:${ticker}`; + const cachedValue = await this.#cache.get(cacheKey); + + if (cachedValue !== undefined) { + return tickerAssets.map( + (asset) => [asset, cachedValue as SpotPrice] as AssetRate, + ); + } + + return this.#assetRates + .spotPrices(ticker) + .then(async (spotPrices) => { + await this.#cache.set(cacheKey, spotPrices, 30000); + return tickerAssets.map( + (asset) => [asset, spotPrices] as AssetRate, + ); + }) + .catch(async (error) => { + await this.#snapClient.emitTrackingError(error as Error); + + this.#logger.warn( + `Failed to fetch spot price for ticker ${ticker}`, + error, + ); + return tickerAssets.map((asset) => [asset, null] as AssetRate); + }); + }, + ); + + const results = await Promise.all(promises); + const ratesMap = new Map(); + + // flatten results from ticker-grouped arrays back to individual asset rates + results.flat().forEach(([asset, rate]) => { + ratesMap.set(asset, rate); + }); + + assetsWithoutTicker.forEach((asset) => { + ratesMap.set(asset, null); + }); + + this.#logger.debug('BTC rates fetched successfully'); + + return assets.map((asset) => { + const rate = ratesMap.get(asset); + return [asset, rate ?? null]; + }); + } + + async getPriceIntervals( + to: CaipAssetType, + ): Promise { + this.#logger.debug('Fetching BTC historical prices. To %s', to); + + const timePeriods: TimePeriod[] = [ + 'P1D', + 'P7D', + 'P1M', + 'P3M', + 'P1Y', + 'P1000Y', + ]; + const vsCurrency = this.#assetToTicker(to); + + const promises = timePeriods.map(async (timePeriod) => + this.#assetRates + .historicalPrices(timePeriod, vsCurrency) + .then((prices) => ({ timePeriod, prices })) + .catch(async (error) => { + await this.#snapClient.emitTrackingError(error as Error); + + this.#logger.warn( + `Failed to fetch historical prices for period ${timePeriod}`, + error, + ); + return { timePeriod, prices: [] }; + }), + ); + + const results = await Promise.all(promises); + + this.#logger.debug('BTC historical prices fetched successfully'); + return results.reduce( + (acc, { timePeriod, prices }) => { + acc[timePeriod] = prices; + return acc; + }, + {}, + ); + } + + #assetToTicker(asset: CaipAssetType): string | undefined { + const { assetNamespace, assetReference } = parseCaipAssetType(asset); + + if (assetNamespace === 'iso4217') { + return assetReference.toLowerCase(); + } + + if (assetNamespace === 'slip44') { + return slip44[ + assetReference as keyof typeof slip44 + ]?.symbol.toLowerCase(); + } + + return undefined; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts new file mode 100644 index 00000000..42f5c86f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts @@ -0,0 +1,44 @@ +import { mock } from 'jest-mock-extended'; + +import { ConfirmationEvent, type Logger, type SnapClient } from '../entities'; +import { ConfirmationUseCases } from './ConfirmationUseCases'; + +describe('ConfirmationUseCases', () => { + const mockLogger = mock(); + const mockSnapClient = mock(); + + const useCases = new ConfirmationUseCases(mockLogger, mockSnapClient); + + describe('onChange', () => { + it('interface resolves to false on cancel', async () => { + await useCases.onChange('interface-id', ConfirmationEvent.Cancel); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + false, + ); + }); + + it('interface resolves to true on confirm', async () => { + await useCases.onChange('interface-id', ConfirmationEvent.Confirm); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + true, + ); + }); + + it('throws an error if the event is not recognized', async () => { + await expect( + useCases.onChange('interface-id', 'unknown' as ConfirmationEvent), + ).rejects.toThrow('Unrecognized confirmation event'); + }); + + it('propagates an error if resolveInterface fails', async () => { + const error = new Error('resolveInterface failed'); + mockSnapClient.resolveInterface.mockRejectedValue(error); + + await expect( + useCases.onChange('interface-id', ConfirmationEvent.Cancel), + ).rejects.toBe(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts new file mode 100644 index 00000000..8044712c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts @@ -0,0 +1,32 @@ +import type { SnapClient, Logger } from '../entities'; +import { UserActionError, ConfirmationEvent } from '../entities'; + +export class ConfirmationUseCases { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + constructor(logger: Logger, snapClient: SnapClient) { + this.#logger = logger; + this.#snapClient = snapClient; + } + + async onChange(interfaceId: string, event: ConfirmationEvent): Promise { + this.#logger.debug( + 'Event triggered on confirmation: %s. Event: %s', + interfaceId, + event, + ); + + switch (event) { + case ConfirmationEvent.Cancel: { + return this.#snapClient.resolveInterface(interfaceId, false); + } + case ConfirmationEvent.Confirm: { + return this.#snapClient.resolveInterface(interfaceId, true); + } + default: + throw new UserActionError('Unrecognized confirmation event'); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts new file mode 100644 index 00000000..b99de0bc --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -0,0 +1,1127 @@ +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; +import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { + SendFormContext, + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + SendFlowRepository, + SnapClient, + ReviewTransactionContext, + AssetRatesClient, + Logger, + CodifiedError, + SpotPrice, + TransactionBuilder, +} from '../entities'; +import { + ReviewTransactionEvent, + CurrencyUnit, + SendFormEvent, +} from '../entities'; +import { SendFlowUseCases } from './SendFlowUseCases'; +import { CronMethod } from '../handlers'; +import type { AccountUseCases } from './AccountUseCases'; + +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => { + return { + Address: { + from_string: jest.fn(), + }, + Amount: { + from_btc: jest.fn(), + from_sat: jest.fn(), + }, + Psbt: { from_string: jest.fn() }, + }; +}); + +describe('SendFlowUseCases', () => { + const mockLogger = mock(); + const mockSnapClient = mock(); + const mockAccountRepository = mock(); + const mockAccountUseCases = mock(); + const mockSendFlowRepository = mock(); + const mockChain = mock(); + const mockRatesClient = mock(); + const targetBlocksConfirmation = 3; + const fallbackFeeRate = 5.0; + const ratesRefreshInterval = 'PT30S'; + const explorerUrl = 'http://myeplorer.com'; + const mockAccount = mock({ + network: 'bitcoin', + buildTx: jest.fn(), + sign: jest.fn(), + id: 'acc-id', + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, + publicAddress: mock
({ toString: () => 'myAddress' }), + }); + const mockPreferences = mock({ + currency: 'usd', + locale: 'en', + }); + const mockPsbt = mock({ + toString: jest.fn(), + }); + + const useCases = new SendFlowUseCases( + mockLogger, + mockSnapClient, + mockAccountRepository, + mockAccountUseCases, + mockSendFlowRepository, + mockChain, + mockRatesClient, + targetBlocksConfirmation, + fallbackFeeRate, + ratesRefreshInterval, + ); + + describe('displayForm', () => { + beforeEach(() => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + }); + + it('throws error if account not found', async () => { + mockAccountRepository.get.mockResolvedValue(null); + await expect(useCases.display('non-existent-account')).rejects.toThrow( + 'Account not found', + ); + }); + + it('returns undefined if displayInterface returns null', async () => { + mockSnapClient.displayInterface.mockResolvedValue(null); + + const result = await useCases.display('account-id'); + expect(result).toBeUndefined(); + }); + + it('displays Send form and returns PSBT when resolved', async () => { + (Psbt.from_string as jest.Mock).mockReturnValue(mockPsbt); + mockSnapClient.displayInterface.mockResolvedValue('psbtBase64'); + + const result = await useCases.display('account-id'); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockSendFlowRepository.insertForm).toHaveBeenCalledWith({ + balance: '1234', + currency: CurrencyUnit.Bitcoin, + account: { id: 'acc-id', address: 'myAddress' }, + network: 'bitcoin', + feeRate: fallbackFeeRate, + errors: {}, + locale: 'en', + }); + expect(mockSnapClient.displayInterface).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalled(); + expect(Psbt.from_string).toHaveBeenCalledWith('psbtBase64'); + expect(result).toStrictEqual(mockPsbt); + }); + }); + + describe('onChangeForm', () => { + const mockOutPoint = 'txid:vout'; + const mockContext: SendFormContext = { + account: { id: 'account-id', address: 'myAddress' }, + amount: '1000', + balance: '20000', + currency: CurrencyUnit.Bitcoin, + drain: false, + recipient: 'recipientAddress', + errors: { + recipient: mock(), + tx: mock(), + amount: mock(), + }, + feeRate: 2.4, + network: 'bitcoin', + fee: '10', + exchangeRate: { + currency: 'USD', + conversionRate: 100000, + conversionDate: 2025, + }, + backgroundEventId: 'backgroundEventId', + locale: 'en', + }; + const mockTxBuilder = mock({ + addRecipient: jest.fn(), + feeRate: jest.fn(), + drainTo: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockChain.getExplorerUrl.mockReturnValue(explorerUrl); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + }); + + it('throws error unrecognized event', async () => { + await expect( + useCases.onChangeForm( + 'interface-id', + 'randomEvent' as SendFormEvent, + mockContext, + ), + ).rejects.toThrow('Unrecognized event'); + }); + + it('resolves to null on Cancel', async () => { + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Cancel, + mockContext, + ); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + null, + ); + expect(mockSnapClient.cancelBackgroundEvent).toHaveBeenCalledWith( + mockContext.backgroundEventId, + ); + }); + + it('clears state on ClearAmount', async () => { + const expectedContext = { + ...mockContext, + amount: undefined, + drain: undefined, + fee: undefined, + errors: { + ...mockContext.errors, + amount: undefined, + tx: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.ClearAmount, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('clears state on ClearRecipient', async () => { + const expectedContext = { + ...mockContext, + recipient: undefined, + fee: undefined, + errors: { + ...mockContext.errors, + tx: undefined, + recipient: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.ClearRecipient, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('throws error on Review if amount or recipient are not defined', async () => { + await expect( + useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + recipient: undefined, + }), + ).rejects.toThrow('Inconsistent Send form context'); + + await expect( + useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + amount: undefined, + }), + ).rejects.toThrow('Inconsistent Send form context'); + }); + + it('updates interface to the drain transaction review on Confirm', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockPsbt.toString.mockReturnValue('psbtBase64'); + const expectedReviewContext: ReviewTransactionContext = { + from: mockContext.account.address, + explorerUrl, + network: mockContext.network, + amount: '1000', + recipient: 'recipientAddress', + exchangeRate: mockContext.exchangeRate, + currency: mockContext.currency, + sendForm: { ...mockContext, drain: true }, + locale: 'en', + psbt: 'psbtBase64', + }; + + await useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + drain: true, + }); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockContext.feeRate); + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith(mockContext.recipient); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); + expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( + 'interface-id', + expectedReviewContext, + ); + }); + + it('updates interface to the transaction review on Confirm', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockPsbt.toString.mockReturnValue('psbtBase64'); + const expectedReviewContext: ReviewTransactionContext = { + from: mockContext.account.address, + explorerUrl, + network: mockContext.network, + amount: '1000', + recipient: 'recipientAddress', + exchangeRate: mockContext.exchangeRate, + currency: mockContext.currency, + sendForm: mockContext, + locale: 'en', + psbt: 'psbtBase64', + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Confirm, + mockContext, + ); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockContext.feeRate); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + mockContext.amount, + mockContext.recipient, + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); + expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( + 'interface-id', + expectedReviewContext, + ); + }); + + it('falls back to updateForm with tx error when builder.finish throws on Confirm', async () => { + const buildError = new Error('Insufficient funds'); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockTxBuilder.finish.mockImplementation(() => { + throw buildError; + }); + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Confirm, + mockContext, + ); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(buildError); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ + errors: expect.objectContaining({ + tx: buildError, + }), + }), + ); + }); + + it('clears state and set drain to true on SetMax', async () => { + // avoid computing the fee in this test + const testContext = { + ...mockContext, + recipient: undefined, + }; + mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); + + const expectedContext = { + ...testContext, + drain: true, + amount: mockContext.balance, + fee: undefined, + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Max, + testContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('sets recipient on Recipient event', async () => { + (Address.from_string as jest.Mock).mockReturnValue({ + toString: () => 'newAddressValidated', + }); + + // avoid computing the fee in this test + const testContext = { + ...mockContext, + amount: undefined, + }; + + const expectedContext = { + ...testContext, + recipient: 'newAddressValidated', + errors: { + ...mockContext.errors, + tx: undefined, + recipient: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + testContext, + 'newAddress', + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('sets amount on Amount event', async () => { + (Amount.from_btc as jest.Mock).mockReturnValue({ + to_sat: () => BigInt('1111'), // Use different amount than state to verify that we get the result from the toString of the bigint + }); + + const testContext = { + ...mockContext, + recipient: undefined, // avoid computing the fee in this test + }; + + const expectedContext = { + ...testContext, + drain: undefined, + fee: undefined, + amount: '1111', + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Amount, + testContext, + '21000', + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('populate errors successfully', async () => { + const mockError = mock({ + code: 18, + message: 'base58 error', + }); + (Address.from_string as jest.Mock).mockImplementation(() => { + throw mockError as unknown as Error; + }); + + const testContext = { + ...mockContext, + amount: undefined, // avoid computing the fee in this test + }; + + const expectedContext = { + ...testContext, + errors: { + ...testContext.errors, + tx: undefined, + recipient: mockError, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + testContext, + 'notAnAddress', + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('sets amount on Amount: switched currencies', async () => { + (Amount.from_sat as jest.Mock).mockReturnValue({ + to_sat: () => BigInt('22222'), + }); + + const testContext = { + ...mockContext, + currency: CurrencyUnit.Fiat, + exchangeRate: { + currency: 'usd', + conversionRate: 11000, + conversionDate: 2025, + }, + recipient: undefined, // avoid computing the fee in this test + }; + + const expectedContext = { + ...testContext, + drain: undefined, + fee: undefined, + amount: '22222', + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Amount, + testContext, + '100', + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('computes the fee when amount and recipient are filled: drain', async () => { + mockPsbt.fee.mockReturnValue({ + to_sat: () => BigInt(10), + } as unknown as Amount); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue(['txid:vout']); + + const expectedContext = { + ...mockContext, + drain: true, + fee: '10', + amount: String(20000 - 10), + errors: expect.anything(), + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Max, + mockContext, + ); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(['txid:vout']); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith( + expectedContext.feeRate, + ); + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith('recipientAddress'); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('computes the fee when amount and recipient are filled', async () => { + (Address.from_string as jest.Mock).mockReturnValue({ + toString: () => 'newAddressValidated', + }); + mockPsbt.fee.mockReturnValue({ + to_sat: () => BigInt(10), + } as unknown as Amount); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([]); + + const expectedContext = { + ...mockContext, + fee: '10', + recipient: 'newAddressValidated', + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + mockContext, + ); + + expect(mockAccountRepository.get).toHaveBeenCalled(); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalled(); + expect(mockTxBuilder.unspendable).toHaveBeenCalled(); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + mockContext.amount, + 'newAddressValidated', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('returns undefined on SwitchCurrency when no exchangeRate', async () => { + const contextNoRate = { ...mockContext, exchangeRate: undefined }; + + const result = await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextNoRate, + ); + + expect(result).toBeUndefined(); + expect(mockSendFlowRepository.updateForm).not.toHaveBeenCalled(); + }); + + it('switches currency from Bitcoin to Fiat on SwitchCurrency', async () => { + const contextWithRate = { + ...mockContext, + exchangeRate: { + currency: 'USD', + conversionRate: 50000, + conversionDate: 2025, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextWithRate, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ currency: CurrencyUnit.Fiat }), + ); + }); + + it('switches currency from Fiat to Bitcoin on SwitchCurrency', async () => { + const contextFiat = { + ...mockContext, + currency: CurrencyUnit.Fiat, + exchangeRate: { + currency: 'USD', + conversionRate: 50000, + conversionDate: 2025, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextFiat, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ currency: CurrencyUnit.Bitcoin }), + ); + }); + + it('sets account from state on Account', async () => { + const accountId = 'myAccount2'; + mockAccount.publicAddress.toString = () => 'myAddress2'; + mockAccountRepository.get.mockResolvedValue({ + ...mockAccount, + id: accountId, + }); + + const expectedContext = { + account: { id: accountId, address: 'myAddress2' }, + balance: '1234', + errors: {}, + currency: CurrencyUnit.Bitcoin, + network: 'bitcoin', + feeRate: 2.4, + locale: 'en', + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Account, + mockContext, + { + accountId, + addresses: [], + }, + ); + + expect(mockAccountRepository.get).toHaveBeenCalledWith(accountId); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + }); + + describe('onChangeReview', () => { + const mockContext: ReviewTransactionContext = { + from: 'myAddress', + explorerUrl, + network: 'bitcoin', + amount: '10000', + currency: CurrencyUnit.Bitcoin, + recipient: 'recipientAddress', + sendForm: { + network: 'bitcoin', + } as SendFormContext, + locale: 'en', + psbt: 'psbt', + }; + + it('throws error unrecognized event', async () => { + await expect( + useCases.onChangeReview( + 'interface-id', + 'randomEvent' as ReviewTransactionEvent, + mockContext, + ), + ).rejects.toThrow('Unrecognized event'); + }); + + it('resolves to null on HeaderBack if missing send form in context', async () => { + await useCases.onChangeReview( + 'interface-id', + ReviewTransactionEvent.HeaderBack, + { ...mockContext, sendForm: undefined }, + ); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + null, + ); + }); + + it('reverts interface back to send form if present in context', async () => { + const id = 'interface-id'; + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + + await useCases.onChangeReview( + id, + ReviewTransactionEvent.HeaderBack, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + id, + mockContext.sendForm, + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockContext.network, + ); + }); + + it('resolves to the transaction request on Send', async () => { + await useCases.onChangeReview( + 'interface-id', + ReviewTransactionEvent.Send, + mockContext, + ); + + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + mockContext.psbt, + ); + }); + }); + + describe('refreshRates', () => { + const mockFeeEstimates = mock({ get: jest.fn() }); + const mockContext: SendFormContext = { + account: { id: 'account-id', address: 'myAddress' }, + balance: '20000', + currency: CurrencyUnit.Bitcoin, + recipient: 'recipientAddress', + errors: {}, + network: 'bitcoin', + feeRate: fallbackFeeRate, + locale: 'en', + }; + const mockExchangeRates = { + price: 200000, + }; + const mockFeeRate = 4.4; + + beforeEach(() => { + mockSendFlowRepository.getContext.mockResolvedValue(mockContext); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockRatesClient.spotPrices.mockResolvedValue( + mockExchangeRates as SpotPrice, + ); + mockSnapClient.scheduleBackgroundEvent.mockResolvedValue('event-id'); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + }); + + it('schedules next event if fetching rates fail', async () => { + const error = new Error('getFeeEstimates'); + mockChain.getFeeEstimates.mockRejectedValueOnce(error); + + await useCases.refresh('interface-id'); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + network: 'bitcoin', + backgroundEventId: 'event-id', + }, + ); + }); + + it('sets fee and exchange rates successfully', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + + await useCases.refresh('interface-id'); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: ratesRefreshInterval, + method: CronMethod.RefreshRates, + params: { interfaceId: 'interface-id' }, + }); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + exchangeRate: { + conversionRate: mockExchangeRates.price, + conversionDate: expect.any(Number), + currency: 'USD', + }, + feeRate: mockFeeRate, + }, + ); + }); + + it('does not set exchange rate if network is not bitcoin', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + mockSendFlowRepository.getContext.mockResolvedValueOnce({ + ...mockContext, + network: 'notBitcoin' as Network, + }); + + await useCases.refresh('interface-id'); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + network: 'notBitcoin', + feeRate: mockFeeRate, + }, + ); + }); + + it('does not set exchange rate if currency is not supported', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + mockSnapClient.getPreferences.mockResolvedValue({ + ...mockPreferences, + currency: 'unknown', + }); + const error = new Error('spotPrices failed'); + mockRatesClient.spotPrices.mockRejectedValue(error); + + await useCases.refresh('interface-id'); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + feeRate: mockFeeRate, + }, + ); + }); + + it('propagates error if scheduleBackgroundEvent fails', async () => { + const error = new Error('scheduleBackgroundEvent failed'); + mockSnapClient.scheduleBackgroundEvent.mockRejectedValue(error); + + await expect(useCases.refresh('interface-id')).rejects.toThrow(error); + }); + + it('returns undefined when context is not found', async () => { + const error = new Error('Missing context'); + mockSendFlowRepository.getContext.mockRejectedValue(error); + + const result = await useCases.refresh('interface-id'); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(result).toBeUndefined(); + }); + }); + + describe('confirmSendFlow', () => { + const mockTxBuilder = mock(); + const mockTemplatePsbt = mock({ + toString: jest.fn().mockReturnValue('template-psbt-base64'), + }); + const mockSignedPsbt = mock({ + toString: jest.fn().mockReturnValue('signed-psbt-base64'), + }); + const mockTransaction = mock(); + const mockAmount = mock(); + const toAddress = 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8'; + const amount = '0.0001'; + const mockFeeEstimates = mock(); + + beforeEach(() => { + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockAmount.to_sat.mockReturnValue(BigInt(10000)); + (Amount.from_btc as jest.Mock).mockReturnValue(mockAmount); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockTemplatePsbt); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + mockFeeEstimates.get.mockReturnValue(2.5); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockChain.getExplorerUrl.mockReturnValue(explorerUrl); + mockSendFlowRepository.insertConfirmSendForm.mockResolvedValue( + 'interface-id', + ); + mockSnapClient.displayUserPrompt.mockResolvedValue(true); + mockAccountUseCases.signPsbt.mockResolvedValue({ + psbt: mockSignedPsbt, + } as any); + (Psbt.from_string as jest.Mock).mockReturnValue(mockSignedPsbt); + mockRatesClient.spotPrices.mockResolvedValue({ + price: { value: 50000, currency: 'usd' }, + } as any); + }); + + it('builds and confirms a regular send transaction', async () => { + const result = await useCases.confirmSendFlow( + mockAccount, + amount, + toAddress, + ); + + expect(Amount.from_btc).toHaveBeenCalledWith(Number(amount)); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(2.5); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '10000', + toAddress, + ); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalled(); + expect(mockSnapClient.displayUserPrompt).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockAccountUseCases.signPsbt).toHaveBeenCalledWith( + mockAccount.id, + mockTemplatePsbt, + 'metamask', + { fill: false, broadcast: true }, + 2.5, + ); + expect(mockAccount.extractTransaction).toHaveBeenCalledWith( + mockSignedPsbt, + ); + expect(result).toBe(mockTransaction); + }); + + it('marks the recipient as isMine when the address belongs to the account', async () => { + const recipientScript = {} as any; + (Address.from_string as jest.Mock).mockReturnValue({ + script_pubkey: recipientScript, + }); + mockAccount.isMine.mockReturnValue(true); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(Address.from_string).toHaveBeenCalledWith( + toAddress, + mockAccount.network, + ); + expect(mockAccount.isMine).toHaveBeenCalledWith(recipientScript); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ isMine: true }), + ); + }); + + it('defaults isMine to false when the recipient address fails to parse', async () => { + const error = new Error('Invalid address'); + (Address.from_string as jest.Mock).mockImplementation(() => { + throw error; + }); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ isMine: false }), + ); + }); + + it('builds a drain transaction when amount equals balance', async () => { + const balanceAmount = mock(); + balanceAmount.to_sat.mockReturnValue(BigInt(10000)); + mockAccount.balance = { + trusted_spendable: balanceAmount, + } as any; + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith(toAddress); + expect(mockTxBuilder.addRecipient).not.toHaveBeenCalled(); + }); + + it('uses fallback fee rate when fee estimates are not available', async () => { + const emptyFeeEstimates = mock(); + emptyFeeEstimates.get.mockReturnValue(undefined); + mockChain.getFeeEstimates.mockResolvedValue(emptyFeeEstimates); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(fallbackFeeRate); + }); + + it('throws error when user cancels confirmation', async () => { + mockSnapClient.displayUserPrompt.mockResolvedValue(false); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('throws error when buildTx fails', async () => { + const buildError = new Error('Failed to build transaction'); + mockAccount.buildTx.mockImplementation(() => { + throw buildError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(buildError); + }); + + it('throws error when finish fails', async () => { + const finishError = new Error('Insufficient funds'); + mockTxBuilder.finish.mockImplementation(() => { + throw finishError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(finishError); + }); + + it('throws error when signPsbt fails', async () => { + const signError = new Error('Failed to sign PSBT'); + mockAccountUseCases.signPsbt.mockRejectedValue(signError); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(signError); + }); + + it('throws error when extractTransaction fails', async () => { + const extractError = new Error('Failed to extract transaction'); + mockAccount.extractTransaction.mockImplementation(() => { + throw extractError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(extractError); + }); + + it('includes exchange rate in context when available', async () => { + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockRatesClient.spotPrices).toHaveBeenCalledWith('usd'); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ + exchangeRate: expect.objectContaining({ + conversionRate: { value: 50000, currency: 'usd' }, + currency: 'USD', + }), + currency: CurrencyUnit.Bitcoin, + }), + ); + }); + + it('handles missing exchange rate gracefully', async () => { + mockRatesClient.spotPrices.mockResolvedValue({ + price: undefined, + } as any); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ + exchangeRate: undefined, + }), + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts new file mode 100644 index 00000000..b04a1b17 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -0,0 +1,638 @@ +import { + type Network, + Address, + Amount, + Psbt, + type Transaction, +} from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import type { InputChangeEvent } from '@metamask/snaps-sdk'; + +import type { + AssetRatesClient, + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + CodifiedError, + CurrencyRate, + Logger, + ConfirmSendFormContext, + ReviewTransactionContext, + SendFlowRepository, + SendFormContext, + SnapClient, +} from '../entities'; +import { + AssertionError, + CurrencyUnit, + networkToCurrencyUnit, + NotFoundError, + ReviewTransactionEvent, + SendFormEvent, + UserActionError, +} from '../entities'; +import type { AccountUseCases } from './AccountUseCases'; +import { CronMethod } from '../handlers'; +import { parsePsbt } from '../handlers/parsers'; + +type SetAccountEventValue = { + accountId: string; +}; + +export class SendFlowUseCases { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + readonly #accountRepository: BitcoinAccountRepository; + + readonly #accountUseCases: AccountUseCases; + + readonly #sendFlowRepository: SendFlowRepository; + + readonly #chainClient: BlockchainClient; + + readonly #ratesClient: AssetRatesClient; + + readonly #targetBlocksConfirmation: number; + + readonly #fallbackFeeRate: number; + + readonly #ratesRefreshInterval: string; + + constructor( + logger: Logger, + snapClient: SnapClient, + accountRepository: BitcoinAccountRepository, + accounts: AccountUseCases, + sendFlowRepository: SendFlowRepository, + chainClient: BlockchainClient, + ratesClient: AssetRatesClient, + targetBlocksConfirmation: number, + fallbackFeeRate: number, + ratesRefreshInterval: string, + ) { + this.#logger = logger; + this.#snapClient = snapClient; + this.#accountRepository = accountRepository; + this.#accountUseCases = accounts; + this.#sendFlowRepository = sendFlowRepository; + this.#chainClient = chainClient; + this.#ratesClient = ratesClient; + this.#targetBlocksConfirmation = targetBlocksConfirmation; + this.#fallbackFeeRate = fallbackFeeRate; + this.#ratesRefreshInterval = ratesRefreshInterval; + } + + async confirmSendFlow( + account: BitcoinAccount, + amount: string, + toAddress: string, + ): Promise { + const amountInSats = Amount.from_btc(Number(amount)).to_sat(); + const templatePsbt = account.buildTx(); + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + const feeEstimates = await this.#chainClient.getFeeEstimates( + account.network, + ); + + const currentFeeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + + templatePsbt.feeRate(currentFeeRate); + + if (amountInSats === account.balance.trusted_spendable.to_sat()) { + templatePsbt.drainWallet().drainTo(toAddress); + } else { + templatePsbt.addRecipient(amountInSats.toString(), toAddress); + } + + const psbt = templatePsbt.finish(); + const currency = networkToCurrencyUnit[account.network]; + + let isMine = false; + try { + const recipientScript = Address.from_string( + toAddress, + account.network, + ).script_pubkey; + isMine = account.isMine(recipientScript); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + isMine = false; + } + + // TODO: add all the necessary properties we need here + const context: ConfirmSendFormContext = { + from: account.publicAddress.toString(), + explorerUrl: this.#chainClient.getExplorerUrl(account.network), + amount: amountInSats.toString(), + recipient: toAddress, + psbt: psbt.toString(), + currency, + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + network: account.network, + locale, + isMine, + }; + + const interfaceId = + await this.#sendFlowRepository.insertConfirmSendForm(context); + + // Blocks and waits for user actions. + const confirmed = + await this.#snapClient.displayUserPrompt(interfaceId); + + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + + // sign and broadcast + const signedPsbt = ( + await this.#accountUseCases.signPsbt( + account.id, + psbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + currentFeeRate, + ) + ).psbt; + + const parsedPsbt = parsePsbt(signedPsbt); + return account.extractTransaction(parsedPsbt); + } + + async display(accountId: string): Promise { + this.#logger.debug('Displaying Send form. Account: %s', accountId); + + const account = await this.#accountRepository.get(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + const { locale } = await this.#snapClient.getPreferences(); + + const context: SendFormContext = { + balance: account.balance.trusted_spendable.to_sat().toString(), + currency: networkToCurrencyUnit[account.network], + account: { + id: account.id, + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow + }, + network: account.network, + feeRate: this.#fallbackFeeRate, + errors: {}, + locale, + }; + + const interfaceId = await this.#sendFlowRepository.insertForm(context); + + // Asynchronously start the fetching of rates background loop. + /* eslint-disable no-void */ + void this.#refreshRates(interfaceId, context); + + // Blocks and waits for user actions + const psbt = await this.#snapClient.displayInterface(interfaceId); + if (!psbt) { + return undefined; + } + + this.#logger.info('PSBT generated successfully'); + return Psbt.from_string(psbt); + } + + async onChangeForm( + id: string, + event: SendFormEvent, + context: SendFormContext, + value?: InputChangeEvent['value'], + ): Promise { + this.#logger.debug( + 'Event triggered on send form: %s. Event: %s', + id, + event, + ); + + switch (event) { + case SendFormEvent.Cancel: { + if (context.backgroundEventId) { + await this.#snapClient.cancelBackgroundEvent( + context.backgroundEventId, + ); + } + return this.#snapClient.resolveInterface(id, null); + } + case SendFormEvent.ClearRecipient: { + const updatedContext = { ...context }; + delete updatedContext.recipient; + delete updatedContext.errors.recipient; + delete updatedContext.errors.tx; + delete updatedContext.fee; + + return this.#sendFlowRepository.updateForm(id, updatedContext); + } + case SendFormEvent.ClearAmount: { + const updatedContext = { ...context }; + delete updatedContext.amount; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + delete updatedContext.drain; + + return this.#sendFlowRepository.updateForm(id, updatedContext); + } + case SendFormEvent.Confirm: { + return this.#handleConfirm(id, context); + } + case SendFormEvent.Max: { + return this.#handleSetMax(id, context); + } + case SendFormEvent.Recipient: { + return this.#handleSetRecipient(id, context, value as string); + } + case SendFormEvent.Amount: { + return this.#handleSetAmount(id, context, value as string); + } + case SendFormEvent.SwitchCurrency: { + if (!context.exchangeRate) { + return undefined; + } + + const updatedContext = { ...context }; + updatedContext.currency = + context.currency === CurrencyUnit.Fiat + ? networkToCurrencyUnit[context.network] + : CurrencyUnit.Fiat; + + return this.#sendFlowRepository.updateForm(id, updatedContext); + } + case SendFormEvent.Account: { + return this.#handleSetAccount( + id, + context, + value as SetAccountEventValue, + ); + } + case SendFormEvent.Asset: { + // Do nothing as there are no other assets + return undefined; + } + default: + throw new UserActionError('Unrecognized event'); + } + } + + async onChangeReview( + id: string, + event: ReviewTransactionEvent, + context: ReviewTransactionContext, + ): Promise { + this.#logger.debug( + 'Event triggered on transaction review: %s. Event: %s', + id, + event, + ); + + switch (event) { + case ReviewTransactionEvent.HeaderBack: { + // If we come from a send form, we display it again, otherwise we resolve the interface (reject) + if (context.sendForm) { + await this.#sendFlowRepository.updateForm(id, context.sendForm); + return this.#refreshRates(id, context.sendForm); + } + return this.#snapClient.resolveInterface(id, null); + } + case ReviewTransactionEvent.Send: { + return this.#snapClient.resolveInterface(id, context.psbt); + } + default: + throw new UserActionError('Unrecognized event'); + } + } + + async #handleSetMax(id: string, context: SendFormContext): Promise { + let updatedContext: SendFormContext = { + ...context, + amount: context.balance, + drain: true, + }; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + + updatedContext = await this.#computeFee(updatedContext); + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #getExchangeRate( + network: Network, + currency: string, + ): Promise { + // Exchange rate is only relevant for Bitcoin + if (network === 'bitcoin') { + try { + const spotPrice = await this.#ratesClient.spotPrices(currency); + + if (spotPrice.price === undefined || spotPrice.price === null) { + this.#logger.warn( + `Exchange rate API returned invalid price for ${currency}`, + ); + return undefined; + } + + return { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + // exchange rates are optional display information - don't fail if unavailable + this.#logger.warn( + `Failed to fetch exchange rate for ${currency}. Error: %s`, + error, + ); + return undefined; + } + } + + return undefined; + } + + async #handleSetRecipient( + id: string, + context: SendFormContext, + formState: string, + ): Promise { + let updatedContext = { ...context }; + delete updatedContext.errors.recipient; + delete updatedContext.errors.tx; + + try { + updatedContext.recipient = Address.from_string( + formState, + context.network, + ).toString(); + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + // We do not track this error as it is a user input. + + this.#logger.error( + `Invalid recipient. Error: %s`, + (error as CodifiedError).message, + ); + + updatedContext.errors = { + ...updatedContext.errors, + recipient: error as CodifiedError, + }; + } + + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #handleSetAmount( + id: string, + context: SendFormContext, + formState: string, + ): Promise { + let updatedContext = { ...context }; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + delete updatedContext.drain; + + try { + let amount: Amount; + if (context.currency === CurrencyUnit.Fiat && context.exchangeRate) { + // keep everything in sats (integers) to avoid FP drift + const sats = Math.round( + (Number(formState) * 1e8) / context.exchangeRate.conversionRate, + ); + amount = Amount.from_sat(BigInt(sats)); + } else { + // expects values to be entered in BTC and not satoshis + amount = Amount.from_btc(Number(formState)); + } + + updatedContext.amount = amount.to_sat().toString(); + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + // We do not track this error as it is a user input. + + this.#logger.error( + `Invalid amount. Error: %s`, + (error as CodifiedError).message, + ); + + updatedContext.errors = { + ...updatedContext.errors, + amount: error as CodifiedError, + }; + } + + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #handleConfirm(id: string, context: SendFormContext): Promise { + if (context.amount && context.recipient) { + if (context.backgroundEventId) { + await this.#snapClient.cancelBackgroundEvent(context.backgroundEventId); + } + + const account = await this.#accountRepository.get(context.account.id); + if (!account) { + throw new NotFoundError('Account removed while confirming send flow'); + } + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( + context.account.id, + ); + + const builder = account + .buildTx() + .feeRate(context.feeRate) + .unspendable(frozenUTXOs); + + if (context.drain) { + builder.drainWallet().drainTo(context.recipient); + } else { + builder.addRecipient(context.amount, context.recipient); + } + + try { + const psbt = builder.finish(); + const reviewContext: ReviewTransactionContext = { + from: context.account.address, + explorerUrl: this.#chainClient.getExplorerUrl(context.network), + network: context.network, + amount: context.amount, + recipient: context.recipient, + exchangeRate: context.exchangeRate, + currency: context.currency, + psbt: psbt.toString(), + sendForm: context, + locale: context.locale, + }; + + return this.#sendFlowRepository.updateReview(id, reviewContext); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + this.#logger.error( + `Failed to build PSBT on Confirm. Error: %s`, + (error as CodifiedError).message, + ); + + const errContext = { + ...context, + errors: { + ...context.errors, + tx: error as CodifiedError, + }, + }; + return await this.#sendFlowRepository.updateForm(id, errContext); + } + } + + throw new AssertionError('Inconsistent Send form context'); + } + + async #handleSetAccount( + id: string, + context: SendFormContext, + formState: SetAccountEventValue, + ): Promise { + const account = await this.#accountRepository.get(formState.accountId); + if (!account) { + throw new NotFoundError('Account not found when switching', { + id: formState.accountId, + }); + } + + // We "reset" the context with the new account + const newContext: SendFormContext = { + balance: account.balance.trusted_spendable.to_sat().toString(), + account: { + id: account.id, + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow + }, + errors: {}, + currency: context.currency, + network: context.network, + feeRate: context.feeRate, + locale: context.locale, + }; + + return await this.#sendFlowRepository.updateForm(id, newContext); + } + + async refresh(id: string): Promise { + try { + const context = await this.#sendFlowRepository.getContext(id); + return this.#refreshRates(id, context); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + // We do not throw as this is probably due to a scheduled event executing after the interface has been removed. + this.#logger.debug('Context not found in send flow:', id, error); + return undefined; + } + } + + async #refreshRates(id: string, context: SendFormContext): Promise { + const { network } = context; + let updatedContext = { ...context }; + + const { locale, currency } = await this.#snapClient.getPreferences(); + + try { + const feeEstimates = await this.#chainClient.getFeeEstimates(network); + + updatedContext.feeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? + this.#fallbackFeeRate; + + updatedContext.exchangeRate = await this.#getExchangeRate( + network, + currency, + ); + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + + // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. + this.#logger.warn( + `Failed to fetch rates in send form: %s. Error: %s`, + id, + error, + ); + } + + updatedContext.backgroundEventId = + await this.#snapClient.scheduleBackgroundEvent({ + duration: this.#ratesRefreshInterval, + method: CronMethod.RefreshRates, + params: { interfaceId: id }, + }); + updatedContext.locale = locale; // Take advantage of the loop to update the locale as well + + await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #computeFee(context: SendFormContext): Promise { + const { amount, recipient, drain, balance } = context; + if (amount && recipient) { + const { id } = context.account; + const account = await this.#accountRepository.get(id); + if (!account) { + throw new NotFoundError('Account removed while sending', { id }); + } + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs(id); + + try { + const builder = account + .buildTx() + .feeRate(context.feeRate) + .unspendable(frozenUTXOs); + + if (drain) { + const psbt = builder.drainWallet().drainTo(recipient).finish(); + const fee = psbt.fee().to_sat(); + const realAmount = BigInt(balance) - fee; + return { + ...context, + fee: fee.toString(), + amount: realAmount.toString(), + balance, + }; + } + + const psbt = builder.addRecipient(amount, recipient).finish(); + return { ...context, fee: psbt.fee().to_sat().toString(), balance }; + } catch (error) { + // We do not track this error as it is a form validation feedback. + + this.#logger.error( + `Failed to build PSBT. Error: %s`, + (error as CodifiedError).message, + ); + + return { + ...context, + errors: { + ...context.errors, + tx: error as CodifiedError, + }, + }; + } + } + + return context; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts new file mode 100644 index 00000000..15721d73 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -0,0 +1,4 @@ +export * from './AccountUseCases'; +export * from './SendFlowUseCases'; +export * from './AssetsUseCases'; +export * from './ConfirmationUseCases'; diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json new file mode 100644 index 00000000..99b45e34 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", + "exactOptionalPropertyTypes": false, + "types": ["jest"] + }, + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] +}