diff --git a/.prettierignore b/.prettierignore index f68dcb0..403b1a3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ coverage-ts __generated__ coverage +docs/plans +.claude diff --git a/.yarnrc.yml b/.yarnrc.yml index 2a28b5a..1378ca4 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -3,6 +3,10 @@ nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.2.2.cjs enableScripts: false +# The registry's bulk-advisories endpoint regularly takes minutes to answer a +# tree this size, and yarn's 60s default killed the CI audit gate mid-request. +httpTimeout: 300000 + # --- Dependency-audit escape hatch ------------------------------------------- # The CI "Audit dependencies" step runs: # yarn npm audit --all --severity high --recursive diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7a81b..5e463b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,202 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Shared Casper transaction, signing and submission layer (Phase 1).** New + `domain/casperTransactions` (`ICasperSigner`, `ICasperTransactionsRepository`, + `CasperTransactionsError`), a `CasperTransactionsRepository` that owns node-RPC client + construction, node-time drift correction and API-version detection, and submits either a + `putTransaction` (node 2.x) or the legacy `putDeploy` (node 1.x) — mirroring mobile/extension + byte-for-byte. It exposes both a composed path (`sendTokenTransfer`, `sendNftTransfer`, + `sendDelegation` — build, sign, submit in one call, mobile parity) and a granular one + (`signTransaction` / `sendSignedTransaction`, for the extension's multi-window sign-then-submit + UX), plus `sendDexTransaction` for the swap flow's built artifact. +- **Shared Ledger layer (Phase 2).** New `domain/ledger` (`LedgerEventStatus` — the union of + both apps' event sets, `ILedgerEvent`, `LedgerAccount`, `SignResult`, transport types, + `LedgerError`, `LEDGER_ERROR_STATUSES`, `isLedgerErrorEvent`) and `CasperLedgerService` + (`src/data/ledger`, root-exported), one device class replacing the near-identical + implementations in both apps. Transport creation, availability checks, pairing-invalidation + classification and session restore are injected, so the same service drives Web HID/USB and + React Native BLE. The on-device identity pre-flight the extension had now applies to both. + The Casper app object is injected too (`createLedgerApp`), so `@zondax/ledger-casper` and + `@ledgerhq/hw-transport` stay out of the import graph: both are **optional peer dependencies**, + installed only by clients that use Ledger, and `domain/ledger` declares the transport and app + shapes it needs (`ILedgerTransport`, `ILedgerCasperApp`) instead of importing them. Adds `rxjs` + as a dependency. +- **`createLedgerSigner`** — presents an `ICasperLedgerService` as an `ICasperSigner`, so hardware + and software keys drive the same send paths. `supportsTransactionV1Cb` and the session-restore + callback are bound at signer construction rather than passed per transfer, and a `LedgerError` + raised inside a repository method reaches the caller unwrapped. +- **`createPrivateKeySigner`** (`src/data/signers`) — an `ICasperSigner` over the + `{publicKeyHex, secretKeyBase64}` pair both apps already store; the software-key counterpart to + the Phase-2 Ledger signer. +- **Pure transaction builders** (`src/utils/casperSdk/tx-builders.ts`, root-exported): + `buildCsprTransferTransactions`, `buildCep18TransferTransactions`, + `buildNftTransferTransactions`, `buildAuctionManagerTransactions` and + `AuctionManagerEntryPointMap` — each returns the `{transaction, fallbackDeploy}` pair mobile and + the extension hand-roll today. The NFT fallback deploy is now built directly via + `makeNftTransferDeploy` instead of the `casperNetworkApiVersion: '1.5.8'` round-trip hack (the + two are provably byte-identical). +- `isValidCasperPublicKey` (`src/utils/casperSdk/validation.ts`, root-exported) and shared + message/key helpers in `src/utils/transactions.ts`: `createCasperMessageBytes`, + `isTransactionSignedBy`, `getPrivateKeyHexFromSecretKey`. +- `setupRepositories` / `setupSigningRepositories` return a `casperTransactionsRepository` and + accept an optional `rpcOptions` (`ICasperRpcOptions`: `handlerType`, `referrerMode`, + `authorizationHeader`), threaded into both `casperTransactionsRepository` and + `dexContractRepository`. Defaults stay browser-safe (`fetch` + `fetch-referrer`); mobile passes + `{ handlerType: 'axios', referrerMode: 'referer-header' }`. +- Package root additionally exports `./src/data/signers`, `./src/utils/casperSdk/tx-builders` and + `./src/utils/casperSdk/validation`. +- **Framework-neutral swap/wrap flow layer.** New `domain/flows` (`IFlowHandle`, + `ISwapFlowRunner`/`IWrapFlowRunner`, `SwapFlowEvent`/`WrapFlowEvent`, `ISwapFlowResult`/ + `IWrapFlowResult`, the pure `swapFlowReducer`/`wrapFlowReducer`) and `src/data/flows` + (root-exported): `createSwapFlowRunner`/`createWrapFlowRunner` build the approve → settle → + swap → settle sequence as an `async function*`, lifted to a hot, replayed `Observable` + (`events$`) via `shareReplay({ bufferSize: Infinity, refCount: false })`. Unsubscribing from + `events$` never cancels a running flow — only the explicit `handle.cancel()` does — so a closed + UI surface never abandons or duplicates a submitted transaction; `runner.getActive(id)` lets a + remounted surface reattach to a flow that is still running. +- **`ITransactionStatusRepository`** (`domain/transactionStatus`), implemented by + `TransactionStatusRepository` and returned as `transactionStatusRepository` from + `setupRepositories()`. Polls node RPC (`observeTransaction`/`waitForTransaction`) until a + submitted transaction executes, distinguishing a `TransactionTimeoutError` ("we stopped + waiting") from an executed `ITransactionOutcome` with `status: 'failure'` ("the chain rejected + it") — settlement is now core-owned instead of each app polling for itself. +- **`ICasperLedgerService.ledgerEvents$`** — an `Observable` alongside the existing + callback-based `subscribeToLedgerEventStatus`, so a flow runner can merge device-prompt events + into its own `events$` via the `ledgerEvents$` dependency. +- **`useReviewSwap`/`useReviewWrap` now subscribe to `swapFlowRunner`/`wrapFlowRunner`** instead + of owning the sign/submit/settle sequence themselves; `ISwapDependencies` carries + `swapFlowRunner`/`wrapFlowRunner` in place of `signer`. See `docs/swap-react-integration.md`. +- **`IDexConfig.expectedProxyWasmSha256`** (optional, strongly recommended) — hex sha256 of + `proxy_caller.wasm`. The bytes run as session code in the caller's account context with access + to their main purse, and the wallet UI and Ledger prompt show only "ModuleBytes"; set this and + the loaded bytes are verified once, refusing the build on a mismatch. The library pins no value: + the binary is a per-deployment artifact of the DEX contracts. +- **`IDexContractRepository.buildRevokeApprovalTransaction`** — an `approve` of `0` to the trade + contract, clearing the allowance a swap leaves standing. Nothing revokes automatically; read the + standing amount with `getAllowance`. +- **`ISwapFlowRunner`/`IWrapFlowRunner` expose `readonly publicKey`.** A runner is bound to one + account for its lifetime, so rebuild both when the active account changes. The review hooks + refuse to start a flow whose runner disagrees with `activePublicKey`, raising the new + `FlowError`. +- **`isLedgerSignatureCancelled`** (`domain/ledger`, with `LEDGER_CANCELLATION_STATUSES`) — the + default `isCancellationError` for both flows, so an on-device rejection is a cancellation rather + than a failure. `LedgerError` now carries its `ledgerEvent`. +- **`useSwapTokens` returns `quotedTrade`** (`ISwapQuotedTrade | null`) — the two amounted + tokens, the route and the quote type, all read off one quote. +- `KeyPairMismatchError` (`domain/casperTransactions`), raised by `createPrivateKeySigner` when + the supplied `publicKeyHex` does not belong to the supplied secret key. +- `useReviewWrap` returns `ledgerEvent`, matching `useReviewSwap`. + +### Changed + +- **BREAKING — `useReviewSwap` takes one `trade` bundle** instead of separate `firstToken`, + `secondToken`, `path` and `quoteType` params; pass `useSwapTokens`'s `quotedTrade` through. + Nothing tied the four together before, so an input amount could be paired with a previous + quote's output bound. `confirmSwap` is a no-op while `trade` is `null`. +- **BREAKING — `IDexConfig.wrappedCsprContractPackageHash` is removed.** It is a parameter of + `setupRepositories` / `setupSigningRepositories` / `setupDataRepositories` instead, so it cannot + diverge from the address `swapRepository` keys its synthetic native-CSPR token off. +- **`ITransactionOutcome`, `ISwapFlowResult` and `IWrapFlowResult` are discriminated unions.** + `swap:confirmed`/`wrap:confirmed` carry `ITransactionSuccessOutcome`; a `'failed'` result must + carry its `error` and a `'success'` one cannot. On the success arm `outcome` is still absent when + `awaitSettlement` was `false` — read it, not `status`, to tell submitted from settled. +- **`createPrivateKeySigner` verifies the key pair before signing**, raising + `KeyPairMismatchError`. The curve is taken from the supplied `publicKeyHex`, so a mismatched pair + previously signed under the wrong curve and was rejected only by the node, after payment. +- `getTransactionErrorMessage` renders a `LedgerError` as its device status; its `message` is the + JSON of the whole event, public key and transaction hash included. +- `swapRepository` is built on its own `HttpDataProvider`, so `httpAuthorizationHeader` — an + apisauce instance-level default — no longer reaches the trade API host. +- `DexContractRepository` refuses to build against an empty contract package hash rather than + signing a call to a zero-length address. The shipped defaults are `''` for devnet and integration. +- **`DexContractRepository`'s node-RPC client now sets the CSPR.cloud proxy referrer by + default.** It previously built its client with no referrer at all; it now goes through the same + `createCasperRpcClient` helper as `casperTransactionsRepository` and `txSignatureRequest`, so an + un-configured consumer picks up the `fetch` + `fetch-referrer` default. Pass `rpcOptions` to + `DexContractRepository` (or via `setupRepositories`) to opt out. +- **`casper-js-sdk` is pinned to exactly `5.1.1`** (was `5.1.0`). Consuming apps pin the same + version: a mismatch duplicates the SDK in the bundle and puts transaction bytes on two + different builders. + +### Removed + +- **`createDexTransactionSender`, `IDexTransactionSender`, `ITransactionCallbacks`** + (`domain/dex`) — superseded by `createSwapFlowRunner`/`createWrapFlowRunner`, which consume an + `ICasperSigner` directly instead of wrapping it in a sender. Settlement is now core-owned via + `transactionStatusRepository`, so consumers no longer inject a `waitForTransaction` callback. +- **`ApprovalState`** and the React hooks that used to orchestrate the sign/submit/settle + pipeline: `useSwapStates`, `useTransactionStatuses`, `useTokenApprovalFlow`, + `useSwapTransaction`, `useWrapTransaction`. That orchestration now lives in the flow layer + (`src/data/flows`) and the two review hooks that subscribe to it. + `dexContractRepository.checkApprovalRequired` is unaffected and stays public. + +## [2.0.0] - 2026-08-30 — Swap / DEX + +### Added + +- **Swap / DEX domain and data layer.** New `domain/swap` and `domain/dex` (entities, repository + interfaces, `SwapError` / `DexError`), `repositories/swap` (trade API: token list, quotes) and + `repositories/dex` (`DexContractRepository` — allowance reads and the approval, swap, wrap and + unwrap transaction builders), plus `dto/swap` mappers. Wired into `setupRepositories()` as + `swapRepository` and `dexContractRepository`. +- **`src/react/` — a React hook layer** for the trade form, the review modal and the wrap/unwrap + flow. Deep-importable as `casper-wallet-core/src/react` and free of `casper-js-sdk`. `react` + and `@tanstack/react-query` are optional peer dependencies; a consumer that does not import + this path needs neither. See `docs/swap-react-integration.md`. +- `setupRepositories` / `setupSigningRepositories` accept `dexConfig`; `setupDataRepositories` + accepts `tradeApiByNetworkUrl` and `wrappedCsprContractPackageHash`. +- Swap helpers in the `utils` barrel: `amounts`, `swap`, `decimal`. + +### Changed + +- **BREAKING — three published domain interface fields renamed to camelCase.** Consuming code + reading the old names will not compile: + - `INft.owner_reverse_lookup_mode` → `INft.ownerReverseLookupMode` + - `IAppMarketingEvent.image_url` → `IAppMarketingEvent.imageUrl` + - `IOnRampCurrencyItem.type_id` → `IOnRampCurrencyItem.typeId` +- **`getBlockchainAmount` now truncates instead of rounding half-up.** It previously used + decimal.js's default `ROUND_HALF_UP`; it now uses `ROUND_DOWN`, so it can never hand back more + base units than the caller typed. An amount whose fraction extends past `decimals` now + converts one base unit lower — `getBlockchainAmount('1.9999999995', 9)` returns + `'1999999999'`, previously `'2000000000'`; `getBlockchainAmount('0.0000000005', 9)` returns + `'0'`, previously `'1'`. This is an exported util: it affects transfer and payment amounts in + consuming apps, not only the swap flow. +- **`formatFiatBalance` now tests the one-cent floor against the actual amount, not the rounded + one.** With the default `decimals = 2`, a balance in `[0.005, 0.01)` renders `<$0.01` where it + previously rounded up to `$0.01`. This is deliberate and applies wallet-wide, not only to + swap: it changes the rendered fiat string on existing deploy-history rows and CEP-18 token + rows, through `formatFiatAmount`, `getCep18FiatAmount` and `getCsprFiatAmount`. `getFiatAmount` + passes `decimals: 4` and is unaffected. +- **`getDecimalTokenBalance` is now exact at any size.** It shifts the decimal point instead of + dividing at decimal.js's default 20-significant-digit precision, so a raw balance above roughly + 10²⁰ base units keeps every digit: `getDecimalTokenBalance('123456789012345678901234', 9)` now + returns `'123456789012345.678901234'`, previously `'123456789012345.6789'`. It backs + `Cep18TokenDto.decimalBalance` and every deploy DTO's `decimalAmount`, so it changes rendered + amounts on the token list and deploy history too. Reachable for an 18-decimal CEP-18 token; CSPR + at 9 decimals stays under the bound. +- `IDexConfig.getProxyWasm` is required. `dexConfig` as a whole stays optional — omit it and + `dexContractRepository` still builds approvals — but supplying a `dexConfig` without the proxy + WASM loader is now a compile error rather than a runtime failure at the Confirm button. +- `DexContractRepository.getAllowance` rejects with a `DexError` on an RPC failure instead of + resolving `''`. `''` now means only "no allowance entry for this spender". +- `buildSwapTransaction` validates its inputs before encoding: the quoted route must start at the + input token and end at the output token, `slippage` must be within `[0, MAX_SLIPPAGE]` and + `deadline` within `[MIN_DEADLINE, MAX_DEADLINE]`. The on-chain deadline is derived from chain + time (`getLatestBlockTime`) rather than the device clock. +- `calculateMinAmountWithSlippage` and `calculateMaxAmountWithSlippage` throw on a slippage + outside their valid range instead of returning an inverted or unprotected bound. +- `IBuiltDexTransaction` is a discriminated union: exactly one of `transaction` / `deploy` is + present, so a signer adapter narrows with `'transaction' in built` instead of asserting + `deploy ?? transaction!`. +- `useCsprFeeValidation`'s parameters are a union of its two modes. Supplying neither — which + silently validated against an amount of `'0'`, i.e. gas only — no longer compiles. +- Swap failures reach the consumer as the real message rather than a single `'Transaction +failed'` string, scoped to the leg that produced them, and the review flow returns to its + confirm step so it stays retryable. `ApprovalState.transactionHash` is now populated. + ## [1.4.0] - 2026-06-30 — EIP-712 typed-data signing ### Added diff --git a/README.md b/README.md index da51ef3..4a47482 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,28 @@ npm install github:make-software/casper-wallet-core > Requires **Node 20** or **Node ≥ 22**, Yarn 4 (Berry). +### Optional peer dependencies + +Ledger support needs two packages this library never imports itself. They are declared as optional +peers, and the Casper app object is injected through `ICasperLedgerServiceOptions.createLedgerApp`: + +```bash +yarn add @zondax/ledger-casper @ledgerhq/hw-transport +``` + +```ts +import CasperApp from '@zondax/ledger-casper'; +import { createCasperLedgerService } from 'CasperWalletCore'; + +const ledger = createCasperLedgerService({ + createLedgerApp: transport => new CasperApp(transport), +}); +``` + +A client without Ledger installs neither: nothing reachable from the package root imports them, and +`src/sdk-free-modules.test.ts` fails the suite if that changes. `react` and `@tanstack/react-query` +are optional peers on the same footing, needed only for `casper-wallet-core/src/react`. + ## Quick Start ```ts @@ -142,10 +164,26 @@ interface ISetupRepositoriesParams { /** Optional Authorization header forwarded with every HTTP call. */ httpAuthorizationHeader?: string; + + /** Override trade (DEX) API URLs per network. */ + tradeApiByNetworkUrl?: Record; + + /** Override the WCSPR contract-package hash per network. */ + wrappedCsprContractPackageHash?: Record; + + /** + * DEX contract-package hashes, gas price and the proxy WASM loader. Optional as a whole, + * but `getProxyWasm` is required inside it — without it no swap, wrap or unwrap + * transaction can be built. + */ + dexConfig?: IDexConfig; } ``` -All fields are optional — defaults point at the production Casper Wallet API. +All fields are optional — defaults point at the production Casper Wallet API and the +production DEX contracts. See +[docs/swap-react-integration.md](docs/swap-react-integration.md) for `dexConfig` and the +React layer. ### Custom logger @@ -167,18 +205,20 @@ setupRepositories({ debug: true, logger }); Each domain module exposes a repository interface (in `src/domain//repository.ts`) backed by an implementation in `src/data/repositories//`. -| Domain | Repository | Responsibility | -| ----------------------------- | ------------------------------ | ----------------------------------------------------- | -| `domain/accountInfo` | `accountInfoRepository` | Resolve account names, avatars, and verified info | -| `domain/tokens` | `tokensRepository` | Fungible token balances, metadata, price data | -| `domain/nfts` | `nftsRepository` | NFT ownership, metadata, collections | -| `domain/deploys` | `deploysRepository` | Deploy history, parsing, transfer details | -| `domain/validator` | `validatorsRepository` | Validator listings, delegation info, auction state | -| `domain/onRamp` | `onRampRepository` | Fiat on-ramp providers and quote handling | -| `domain/appEvents` | `appEventsRepository` | Wallet-wide announcements / app events | -| `domain/tx-signature-request` | `txSignatureRequestRepository` | Decoding & describing transactions awaiting signature | -| `domain/contractPackage` | `contractPackageRepository` | Contract package metadata lookups | -| `domain/eip712` | `eip712Repository` | EIP-712 typed-data parsing, display, signing | +| Domain | Repository | Responsibility | +| ----------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `domain/accountInfo` | `accountInfoRepository` | Resolve account names, avatars, and verified info | +| `domain/tokens` | `tokensRepository` | Fungible token balances, metadata, price data | +| `domain/nfts` | `nftsRepository` | NFT ownership, metadata, collections | +| `domain/deploys` | `deploysRepository` | Deploy history, parsing, transfer details | +| `domain/validator` | `validatorsRepository` | Validator listings, delegation info, auction state | +| `domain/onRamp` | `onRampRepository` | Fiat on-ramp providers and quote handling | +| `domain/appEvents` | `appEventsRepository` | Wallet-wide announcements / app events | +| `domain/tx-signature-request` | `txSignatureRequestRepository` | Decoding & describing transactions awaiting signature | +| `domain/contractPackage` | `contractPackageRepository` | Contract package metadata lookups | +| `domain/eip712` | `eip712Repository` | EIP-712 typed-data parsing, display, signing | +| `domain/swap` | `swapRepository` | DEX quotes and token listings from the trade API — `getQuote`, `getDexTokens`, `getDexToken` | +| `domain/dex` | `dexContractRepository` | On-chain allowance reads and unsigned transaction builders — `getAllowance`, `checkApprovalRequired`, `getLatestBlockTime`, `buildSwapTransaction`, `buildApprovalTransaction`, `buildWrapTransaction`, `buildUnwrapTransaction` | > ⚠️ Note the naming asymmetry between `domain/` and `data/repositories/` (e.g. `domain/validator` ↔ `repositories/validators`, `domain/tx-signature-request` ↔ `repositories/txSignatureRequest`). Always import from the package root to avoid drift. @@ -194,20 +234,21 @@ Each domain module exposes a repository interface (in `src/domain//repos This package declares `"sideEffects": false`, so a bundler with tree shaking enabled drops the unused half even when you import from the package root. For builds where that cannot be relied on, the SDK-free helpers also have stable deep-import paths: -| Import path | Exports | Links the SDK | -| --------------------------------------------------------- | -------------------------------------------------------- | ------------- | -| `casper-wallet-core/src/utils/casperSdk/accountHash` | `getAccountHashFromPublicKey` | no | -| `casper-wallet-core/src/utils/casperSdk/network` | `getCasperNetworkByChainName` | no | -| `casper-wallet-core/src/utils/casperSdk/blockExplorer` | `getBlockExplorer*Url`, `getContractNftUrl` | no | -| `casper-wallet-core/src/domain` | entities, repository contracts, errors, constants | no | -| `casper-wallet-core/src/setupData` | `setupDataRepositories` — the eight read repositories | no | -| `casper-wallet-core/src/utils/casperSdk/cep-nft-transfer` | `makeNftTransferDeploy`, `makeNftTransferTransaction`, … | **yes** | -| `casper-wallet-core/src/utils/eip712/sign` | EIP-712 signing | **yes** | -| `casper-wallet-core/src/setupSigning` | `setupSigningRepositories` — txSignatureRequest, EIP-712 | **yes** | +| Import path | Exports | Links the SDK | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------- | +| `casper-wallet-core/src/utils/casperSdk/accountHash` | `getAccountHashFromPublicKey` | no | +| `casper-wallet-core/src/utils/casperSdk/network` | `getCasperNetworkByChainName` | no | +| `casper-wallet-core/src/utils/casperSdk/blockExplorer` | `getBlockExplorer*Url`, `getContractNftUrl` | no | +| `casper-wallet-core/src/domain` | entities, repository contracts, errors, constants | no | +| `casper-wallet-core/src/setupData` | `setupDataRepositories` — the nine read repositories | no | +| `casper-wallet-core/src/react` | React hooks for the swap / wrap flow | no | +| `casper-wallet-core/src/utils/casperSdk/cep-nft-transfer` | `makeNftTransferDeploy`, `makeNftTransferTransaction`, … | **yes** | +| `casper-wallet-core/src/utils/eip712/sign` | EIP-712 signing | **yes** | +| `casper-wallet-core/src/setupSigning` | `setupSigningRepositories` — txSignatureRequest, EIP-712, dexContract, casperTransactions, transactionStatus | **yes** | ### Repositories -`setupRepositories()` still constructs all ten repositories and its return shape is unchanged, but it links the SDK, because two of them do. A surface that only renders balances, accounts, tokens, NFTs, validators or deploys should build its repositories with `setupDataRepositories()` from `src/setupData` instead, and construct the signing pair separately — `setupSigningRepositories()` takes the shared `httpDataProvider`, logger and the three repositories it depends on, so both halves still talk through one provider: +`setupRepositories()` still constructs all fourteen repositories and its return shape is unchanged, but it links the SDK, because the five signing ones do (`txSignatureRequest`, `eip712`, `dexContract`, `casperTransactions` and `transactionStatus`). A surface that only renders balances, accounts, tokens, NFTs, validators or deploys should build its repositories with `setupDataRepositories()` from `src/setupData` instead, and construct the signing half separately — `setupSigningRepositories()` takes the shared `httpDataProvider`, logger and the three data repositories it depends on, so both halves still talk through one provider: ```typescript import { setupDataRepositories } from 'casper-wallet-core/src/setupData'; @@ -222,7 +263,7 @@ Note that the SDK-linked modules are re-exported from the package root but **not Two guards keep this from regressing, both in `yarn test`: - `src/utils/casperSdk/accountHash.test.ts` — property-based parity against `casper-js-sdk` for both key algorithms. -- `src/sdk-free-modules.test.ts` — walks the static import graph of each SDK-free entry point and fails if any runtime import reaches `casper-js-sdk`. `import type` is ignored, since it is erased at compile time. +- `src/sdk-free-modules.test.ts` — walks the static import graph of each SDK-free entry point and fails if any runtime import reaches `casper-js-sdk`. `import type` is ignored, since it is erased at compile time. The same file walks the package root for the optional Ledger packages, and there counts type-only imports too: this package ships raw TypeScript, so a consumer that skipped them compiles our sources without them. When adding code to the `domain` layer or to the SDK-free `utils` modules, prefer `import type` for anything used only in type position, and import from the specific module rather than a barrel. @@ -290,7 +331,9 @@ Tests live next to the code they cover (e.g. `src/utils/common.test.ts`, `src/da │ │ ├── eip712/ │ │ ├── env/ │ │ ├── nfts/ +│ │ ├── dex/ │ │ ├── onRamp/ +│ │ ├── swap/ │ │ ├── tokens/ │ │ ├── tx-signature-request/ │ │ └── validator/ @@ -298,6 +341,7 @@ Tests live next to the code they cover (e.g. `src/utils/common.test.ts`, `src/da │ │ ├── data-providers/http/ # apisauce/axios wrapper (IHttpDataProvider) │ │ ├── dto/ # API → domain entity mappers │ │ └── repositories/ # Concrete repository implementations +│ ├── react/ # React hooks for the swap / wrap flow (SDK-free) │ ├── utils/ # Shared helpers (crypto, date, address, deploy, casperSdk, logger, …) │ └── typings/ # Ambient type declarations ├── scripts/ # Tooling (e.g. fixture generation) diff --git a/docs/swap-react-integration.md b/docs/swap-react-integration.md new file mode 100644 index 0000000..1a6ebeb --- /dev/null +++ b/docs/swap-react-integration.md @@ -0,0 +1,590 @@ +# Swap / DEX React integration guide + +This guide is for consumers wiring the swap and WCSPR wrap/unwrap flows into a React app (the +Casper Wallet browser extension and the mobile app). It covers the pieces added under +`src/domain/swap`, `src/domain/dex`, `src/domain/flows`/`src/data/flows` (the framework-neutral +flow layer, root-exported), and the optional React hooks layer in `src/react/`. + +`src/react/` is **not** exported from the package root — it depends on `react` and +`@tanstack/react-query`, both optional peer dependencies, so importing it never forces the SDK +or React onto a consumer that only needs the data layer. Import it by its deep path: + +```ts +import { useSwapTokens, type ISwapDependencies } from 'casper-wallet-core/src/react'; +``` + +## Install + +The package is consumed directly from Git — there is no npm release: + +```bash +yarn add github:make-software/casper-wallet-core +``` + +To use `src/react/`, also install the peer dependencies it expects (already declared as +optional peers in the package's `package.json`, so `yarn`/`npm` won't install them for you): + +```bash +yarn add react@^18 @tanstack/react-query@^5 +``` + +Wrap your app (or the subtree that needs swap/wrap) in a `QueryClientProvider` — every exported +hook that fetches is built on `@tanstack/react-query`. + +## 1. Call `setupRepositories` + +`setupRepositories()` from the package root wires the whole library, including the two new +repositories: + +```ts +import { setupRepositories, CasperNetwork } from 'casper-wallet-core'; + +const { swapRepository, dexContractRepository, tokensRepository } = setupRepositories({ + dexConfig: { + // All fields optional; shipped defaults cover mainnet/testnet. + getProxyWasm: () => loadProxyCallerWasm(), // see "Supplying the proxy WASM" below + }, +}); +``` + +- `swapRepository` (`ISwapRepository`) is HTTP-only and SDK-free — quotes and DEX token + listings, from the trade API. +- `tokensRepository` (`ITokensRepository`) is HTTP-only and SDK-free — every balance and fiat + rate the swap UI shows, from the same wallet API that backs the wallet's own token list. +- `dexContractRepository` (`IDexContractRepository`) links `casper-js-sdk` — allowance reads, + the latest block time, and the four unsigned-transaction builders (`buildApprovalTransaction`, + `buildSwapTransaction`, `buildWrapTransaction`, `buildUnwrapTransaction`). Balances never go + through it: every balance in the library is read from the API. + +If your app already splits `setupDataRepositories()` / `setupSigningRepositories()` to keep the +SDK out of a balances-only bundle, `swapRepository` and `tokensRepository` come back from +`setupDataRepositories()` and `dexContractRepository` from `setupSigningRepositories({ dexConfig, ... })` +— `dexConfig` moved there, alongside the SDK-linked half. + +### `IDexConfig` + +```ts +interface IDexConfig { + tradeContractPackageHash?: Record; // default TradeContractPackageHash + gasPriceTolerance?: number; // default 1 + getProxyWasm: () => Promise; // required for swap and wrap/unwrap builders + expectedProxyWasmSha256?: string; // strongly recommended, see below +} +``` + +Everything is optional except `getProxyWasm`, which every swap and wrap/unwrap build call +needs (approval does not — it's a direct contract-package call, not proxied). `dexConfig` +itself is optional: omit it entirely and `dexContractRepository` still builds approvals, but +swap, wrap and unwrap reject. Supply it and the compiler requires `getProxyWasm`, so the +failure lands at setup rather than at the Confirm button. + +The wrapped-CSPR contract package hash is **not** part of `dexConfig`. `swapRepository` keys its +synthetic native-CSPR token off the same address, so it is one parameter of the setup factories +(`setupRepositories({ wrappedCsprContractPackageHash })`) rather than two knobs that can +disagree — a divergence rejects every native-CSPR swap as an invalid route. + +A network whose `tradeContractPackageHash` or `wrappedCsprContractPackageHash` is empty — which +is the shipped default for devnet and integration — refuses to build rather than signing a call +against a zero-length address. Supply both if you support those networks. + +### Supplying the proxy WASM + +Every swap and wrap/unwrap transaction runs through a WASM proxy (`proxy_caller.wasm`). The +library cannot bundle binary assets, so each platform supplies the bytes itself: + +```ts +// Web (Vite/webpack — inline the bytes at build time, do not fetch them at runtime): +import proxyWasm from './assets/proxy_caller.wasm'; +const getProxyWasm = async () => new Uint8Array(proxyWasm); + +// React Native (bundled asset + a base64/file read appropriate to your RN setup): +const getProxyWasm = async () => { + const base64 = await RNFS.readFile(proxyWasmAssetPath, 'base64'); + return Uint8Array.from(Buffer.from(base64, 'base64')); +}; +``` + +**Ship the bytes as a build-time asset, and set `expectedProxyWasmSha256`.** These bytes execute +as session code in the caller's account context, with access to their main purse — it is the most +powerful payload the library builds, and unlike the bounded `approve` call neither the wallet UI +nor the Ledger prompt shows the user more than "ModuleBytes". A runtime `fetch` widens the +exposure from your own asset pipeline to whatever that URL resolves to on the day. + +```ts +dexConfig: { + getProxyWasm, + // shasum -a 256 proxy_caller.wasm + expectedProxyWasmSha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', +} +``` + +The bytes are hashed once per repository and the build is refused on a mismatch. Without it they +are used as supplied, and nothing downstream can tell a correct binary from a substituted one. + +## 2. Build the dependency object + +Every hook in `src/react/` takes its dependencies as fields on its single object parameter — +there is no context to mount. The only provider still required above the hooks is +`QueryClientProvider`, for every exported hook that fetches. That is all of `src/react/hooks/api` +plus anything reaching them transitively — including the page-level `useSwapTokens` and +`useWrapTokens`, and `useSwapRouteTokens`, which issues its own `useQueries`: + +```tsx +import { useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type ISwapDependencies } from 'casper-wallet-core/src/react'; + +const queryClient = new QueryClient(); + +function TradeScreen() { + const network = useSelector(selectNetwork); + const activePublicKey = useSelector(selectActivePublicKey); + + const deps = useMemo( + () => ({ + swapRepository, + dexContractRepository, + tokensRepository, + network, + // Built once from a signer + setupRepositories(), as shown in step 3. + swapFlowRunner, + wrapFlowRunner, + activePublicKey, + }), + [network, swapFlowRunner, wrapFlowRunner, activePublicKey], + ); + + const swap = useSwapTokens({ ...deps, slippage }); + // ... +} +``` + +Note that each hook takes only the subset it needs, so spreading a full `ISwapDependencies` is a +convenience, not a requirement — a hook can equally be given its fields by hand. This library +holds no live wallet-account state itself; `activePublicKey`, `swapFlowRunner` and +`wrapFlowRunner` are exactly what the host app currently has selected and built — `null` for the +runners and `activePublicKey` until a wallet is connected. + +**Repositories and flow runners must be stable references.** These hooks put dependency objects +in `useCallback`/`useEffect` dependency arrays, so a dependency that gets a new identity on every +render re-triggers those effects — `useTokenBalances`'s CSPR refetch loops indefinitely if +`tokensRepository` is rebuilt on every render instead of held as a stable singleton. A +`swapFlowRunner` rebuilt on every render loses something different: `getActive(id)` is backed by +a map on the runner instance, so every in-flight flow becomes unreachable and a remounted surface +can no longer reattach to a swap that is still running. Keep `swapRepository`, +`dexContractRepository` and `tokensRepository` as module-level (or memoized-once) singletons, and +memoize the `deps` object itself, as in the example above. + +**The flow runners are the exception: they are stable per account, not per app.** See step 3. + +Above `TradeScreen`, only `QueryClientProvider` is required: + +```tsx +function App() { + return ( + + + + ); +} +``` + +## 3. Provide an `ICasperSigner` and build the flow runners + +The library never signs or submits directly, but it does own the whole approve → settle → +swap → settle orchestration: apps no longer implement a flow-level port themselves. Consumers +supply only a key-level `ICasperSigner` (`createPrivateKeySigner` for a software key, +`createLedgerSigner` for Ledger) and hand it, together with the repositories from +`setupRepositories()`, to `createSwapFlowRunner` / `createWrapFlowRunner`: + +```ts +import { + createPrivateKeySigner, + createSwapFlowRunner, + createWrapFlowRunner, + isLedgerSignatureCancelled, + setupRepositories, +} from 'casper-wallet-core'; + +const { casperTransactionsRepository, dexContractRepository, transactionStatusRepository } = + setupRepositories({ dexConfig: { getProxyWasm, expectedProxyWasmSha256 } }); + +const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + +const swapFlowRunner = createSwapFlowRunner({ + network, + publicKey: publicKeyHex, + signer, + supportsTransactionV1: + casperNetworkApiVersion.startsWith('2.') && (isSoftwareKey || ledgerSupportsTransactionV1), + dexContractRepository, + casperTransactionsRepository, + transactionStatusRepository, + // Optional: device prompts interleave with flow progress in the same stream. + ledgerEvents$: ledgerService?.ledgerEvents$, + // Optional: the default already classifies a Ledger `SignatureCanceled` / + // `MsgSignatureCanceled` as a cancellation. Supply one only for a signer of your own. + isCancellationError: isLedgerSignatureCancelled, +}); +``` + +`createWrapFlowRunner` takes the identical dependency shape — build both runners from the same +`deps` object (wrap simply never calls the approval builders). `transactionStatusRepository` +(`ITransactionStatusRepository`) is core-owned: it polls node RPC until the submitted +transaction executes, so there is no `waitForTransaction` callback left for a consumer to +implement. + +**A runner is bound to the `publicKey` and `signer` it was built with, for its whole lifetime.** +The swap is built from, paid by, signed by and delivered to that key — the `to` recipient is +derived from it, not from anything the surface passes at Confirm time. So **rebuild both runners +when the active account changes**, memoized on `activePublicKey`: + +```ts +const { swapFlowRunner, wrapFlowRunner } = useMemo(() => { + if (!activePublicKey || !signer) return { swapFlowRunner: null, wrapFlowRunner: null }; + + const flowDeps = { network, publicKey: activePublicKey, signer, ...repositories }; + + return { + swapFlowRunner: createSwapFlowRunner(flowDeps), + wrapFlowRunner: createWrapFlowRunner(flowDeps), + }; +}, [activePublicKey, signer, network]); +``` + +Both runners expose `publicKey`, and `useReviewSwap` / `useReviewWrap` refuse to start a flow when +it disagrees with `activePublicKey`, surfacing a `FlowError` rather than signing for the wrong +account. That is a backstop, not the mechanism: rebuild the runners. + +Rebuilding drops the previous runner's `getActive` map, so do it on account change only — not on +every render. + +`runner.start(params)` returns an `ISwapFlowHandle` / `IWrapFlowHandle` — a running flow, not a +one-shot promise. `src/react/hooks/swap/useReviewSwap.ts` and +`src/react/hooks/wrap/useReviewWrap.ts` are the reference subscribers (see "Swap review flow" and +"Wrap / unwrap flow" below); a non-React consumer subscribes to `handle.events$` the same way. + +### The flow handle and cancellation semantics + +- **`events$` is hot and replayed.** It is a `shareReplay({ bufferSize: Infinity, refCount: +false })` observable: the flow starts as soon as `start()` is called, regardless of whether + anyone is subscribed, and a subscriber that attaches later — after a modal reopens, or a + screen remounts — receives the full event history from the beginning, not just what happens + next. +- **Unsubscribing never cancels the flow.** Closing a review modal or navigating away + unsubscribes the hook's listener, but the submitted transaction keeps running to completion. + This is deliberate: a closed UI surface must not abandon a swap or wrap that is already on + chain. +- **`handle.cancel()` is the only way to stop a flow**, and only takes effect at the next + `AbortSignal` check inside the flow generator — it cannot un-submit a transaction that has + already been sent. +- **Reattaching:** `runner.getActive(id)` returns the handle for a still-running flow by its + `id`, or `null` once it has finished. A surface that unmounted and remounted (a modal closed + and reopened, an app backgrounded and resumed) uses this to pick the same in-flight flow back + up instead of losing track of it or starting a duplicate. + +### Example: CSPR.click adapter + +CSPR.click only exposes `sign`/`signMessage`, not submission, so it becomes a sign-only +`ICasperSigner` adapter — submission then goes through `casperTransactionsRepository`, not +`click.send`: + +```ts +const csprClickSigner = (clickRef: ICSPRClickSDK, publicKeyHex: string): ICasperSigner => ({ + publicKeyHex, + async signTransaction(tx) { + const { signature, signatureWithPrefix } = await clickRef.sign(tx, publicKeyHex); + return { signature, signatureWithPrefix }; + }, + async getSignedTransaction(tx) { + const { signatureWithPrefix } = await clickRef.sign(tx, publicKeyHex); + tx.setSignature(signatureWithPrefix); + return tx; + }, + async signMessage(message) { + return clickRef.signMessage(message, publicKeyHex); + }, +}); +``` + +A CSPR.click cancellation surfaces as a rejection from `clickRef.sign`/`signMessage` — recognize +it and pass it as `isCancellationError` to `createSwapFlowRunner`/`createWrapFlowRunner` so the +flow yields a `'cancelled'` event (reducer state `'idle'`) instead of a `'failed'` one. + +### Extension / mobile signing pipelines + +The shape is identical for any signing backend — the extension's in-process keyring and the +mobile app's native signing bridge both implement `ICasperSigner` the same way: sign the +transaction hash (already built by the library) and hand the result back. Submission, node-time +drift, and API-version detection are no longer the app's concern — the flow runner drives them +through `casperTransactionsRepository.sendDexTransaction` and `transactionStatusRepository`. + +## 4. Slippage and deadline + +The library keeps no settings state of its own. `slippage` (percent) is a required parameter of +`useSwapTokens` and `useReviewSwap`; `deadline` (minutes) is a required parameter of +`useReviewSwap` only, which threads both into the swap flow's `IStartSwapFlowParams`. +`useSwapTokens` does **not** thread `deadline` anywhere — it quotes and validates the form, it +does not build the transaction — so a consumer that stops at the orchestrator never supplies +one. Where that state lives (in-memory, +`localStorage`, `AsyncStorage`, redux-persist, ...) and how it survives a remount is entirely up +to the host app. + +The library exports the clamp helpers and bounds it used to apply internally, so a consumer can +apply the same limits before persisting or passing a value in: + +```ts +import { + clampSlippageValue, + clampDeadlineValue, + DEFAULT_SLIPPAGE, + DEFAULT_DEADLINE, + MIN_SLIPPAGE, + MAX_SLIPPAGE, + MIN_DEADLINE, + MAX_DEADLINE, +} from 'casper-wallet-core'; +``` + +`clampSlippageValue`/`clampDeadlineValue` clamp to `MIN_SLIPPAGE`/`MAX_SLIPPAGE` and +`MIN_DEADLINE`/`MAX_DEADLINE` respectively (also falling back to the minimum for `NaN`). +`DEFAULT_SLIPPAGE = 3` and `DEFAULT_DEADLINE = 20` are the values to start a fresh consumer's +state with. The hooks pass whatever `slippage`/`deadline` number they're given straight through, +so clamping for the settings UI is the consumer's call — but `buildSwapTransaction` rejects a +`slippage` outside `[0, MAX_SLIPPAGE]` or a `deadline` outside `[MIN_DEADLINE, MAX_DEADLINE]` +with a `DexError` rather than encoding it. Watch the units: a quote's `recommendedSlippageBps` +is in **basis points**, and `slippage` is in **percent**. + +### Warning thresholds (consumer-owned) + +`SWAP_PRICE_IMPACT_WARNING_THRESHOLD` (10%) and `HIGH_SLIPPAGE_WARNING_THRESHOLD` (10%) are +exported for a consumer's UI to compare against a quote's `priceImpact` and the configured +`slippage`. The library does not gate on either — `isFormValid` ignores price impact entirely, +so showing (or blocking on) a high-impact warning is the host app's decision. + +## Error shape: `SwapError` + +Every `ISwapRepository` rejection is a `SwapError` (`domain/swap`), which carries the failed +HTTP response's JSON envelope and status code, not just a message: + +```ts +export type ISwapError = IDomainError & { + data?: string; // JSON envelope of the failed response, when there was one + status?: number; +}; +``` + +Two hooks build directly on this: + +- `useFetchSwapQuote`'s `fetchQuoteErrorCode` parses `error.data` one level down + (`{ data: { error: { code } } }`) to recover the trade API's `FetchQuoteErrorCodes` + (`invalid_input` / `not_found`) for quote-specific error UI. +- `useFetchToken`'s `errorMessage` reads `error.status` directly: a `400` maps to `'Pair not +found'`, anything else falls back to `error.message`. + +Any consumer writing its own error UI against `swapRepository`/`dexContractRepository` calls +can rely on the same `data`/`status` fields being present on `SwapError`. + +## CSPR balance refresh + +`useTokenBalances` fetches the native CSPR balance itself, on mount and whenever +`activePublicKey` changes — this library has no live wallet-account state to push balance +updates from. CEP-18 token balances still auto-refresh +every 30 seconds via `useFetchAccountTokenOwnership`'s `refetchInterval`, but the CSPR leg does +not poll. If your flow needs a fresher CSPR balance than "on account switch" (for example, +right after a transaction is confirmed), call the `refetchCsprBalance` function +`useTokenBalances` returns — `useWrapTokens`'s `onWrapSuccess` is the reference example. + +## Swap review flow + +`useSwapTokens` drives the trade form; `useReviewSwap` drives the review modal that follows it. +The orchestrator hands over one `quotedTrade` bundle — the two amounted tokens, the route and the +quote type, all read off the same quote — and `useReviewSwap` runs the CEP-18 approval (skipped +for a native CSPR input) before the swap: + +```tsx +import { useSwapTokens, useReviewSwap } from 'casper-wallet-core/src/react'; + +function SwapPage({ slippage, deadline }: { slippage: number; deadline: number }) { + const deps = useMemo(/* as in step 2 */); + + const { + selectedTokens, + tokenAmounts, + quotedTrade, + isReviewModalOpen, + closeReviewModal, + onSwapSuccess, + ...rest + } = useSwapTokens({ ...deps, slippage }); + + const { + step, + transactionState, + isProcessing, + confirmSwap, + resetForm, + handleCloseSuccessModal, + transactionHash, + ledgerEvent, + } = useReviewSwap({ + ...deps, + slippage, + deadline, + trade: quotedTrade, + isOpen: isReviewModalOpen, + onSwapSuccess, + onClose: closeReviewModal, + }); + + // ...render form + review modal using the above +} +``` + +**Pass `quotedTrade` whole; do not assemble the four fields yourself.** They carry the +**transaction** amounts, not balances, and they must all come from one quote: `amount_out_min` is +derived from `secondToken.amountRaw` and is only a slippage bound on the trade that +`firstToken.amountRaw` and `path` describe. Pairing a fresh input amount with a previous quote's +output is an unprotected fill if the amount grew and a wasted-gas revert if it shrank, and +`buildSwapTransaction` cannot catch it — it validates the route's token _identity_, not its +amounts. `quotedTrade` is `null` whenever no quote is in hand, and `confirmSwap` is a no-op then. + +The form's own `tokenAmounts` lag the quote by the input debounce, so they are for rendering, not +for building. `slippage` must be the same value passed to `useSwapTokens`, so the quote the user +saw and the bound encoded into the payload agree; the approval amount is derived from it and +needs nothing else from the caller. + +`confirmSwap` calls `swapFlowRunner.start(...)` and subscribes to the resulting handle; +`transactionState` is the reducer's fold of the flow's events into `{ approval, swap }` leg +status, and `ledgerEvent` is the most recent device-prompt event forwarded through +`ledgerEvents$` (`undefined` until one arrives, or if the runner was built without a Ledger +stream). + +Closing the modal (`isOpen: false`) unsubscribes the hook from `events$`, which stops it from +applying further events but never cancels the underlying flow: a submitted swap keeps running. +Reopening the modal resubscribes, replays the flow's full history through the reducer, and +reconstructs the true current state rather than a reset form. See "The flow handle and +cancellation semantics" in step 3. + +### The allowance a swap leaves behind + +A CEP-18 swap approves a bounded amount derived from the trade — never an infinite allowance — +but whatever the swap did not spend stays granted afterwards, including after one that reverted +on chain. Read the standing amount with `dexContractRepository.getAllowance(...)`, and build a +revocation with `dexContractRepository.buildRevokeApprovalTransaction(...)` (an `approve` of `0`, +submitted the same way as any other build). + +Nothing revokes automatically. It is a third signature, confirmation and payment on top of the +swap, it cannot run on the paths where signing itself failed, and it throws away the saving of a +still-sufficient allowance on the user's next swap of the same token. Whether to offer it — and +whether to surface a standing allowance at all — is the surface's call. + +### Retrying, and `resetForm` + +A flow that fails or is cancelled leaves `step` at `'confirm'` with the error in +`transactionState`, and **pressing Confirm again starts a new flow** — the hook's guard is +released when the flow settles, not when the surface asks. You do not need `resetForm` to retry, +and calling it would clear the error message the user is reading. + +`resetForm` clears the hook's local state, and **refuses to do anything while a flow is still +live**. That is deliberate: wiring it to a modal's close handler would otherwise let a second +Confirm start a second approval and a second swap against the same balance, which can stay in +flight for the full settlement timeout. It does not cancel a running flow either — stopping one +is `handle.cancel()`, and nothing else. + +## Wrap / unwrap flow + +WCSPR wrap/unwrap composes the same building blocks as swap, with no approval step (`withdraw` +burns the caller's own WCSPR balance) and no slippage/deadline: + +```tsx +import { useWrapTokens, useReviewWrap } from 'casper-wallet-core/src/react'; + +function WrapPage() { + const deps = useMemo( + () => ({ + swapRepository, + dexContractRepository, + tokensRepository, + network, + swapFlowRunner, + wrapFlowRunner, + activePublicKey, + }), + [network, swapFlowRunner, wrapFlowRunner, activePublicKey], + ); + + const { + direction, + amount, + sourceToken, + destinationToken, + sourceRawAmount, + isFormValid, + isReviewModalOpen, + openReviewModal, + closeReviewModal, + updateAmount, + switchDirection, + onWrapSuccess, + ...rest + } = useWrapTokens(deps); + + const { step, status, error, confirmWrap, handleCloseSuccessModal, ledgerEvent } = useReviewWrap({ + ...deps, + direction, + sourceToken: { + ...sourceToken, + amountFormatted: amount, + // The transaction amount, not the balance: this is what lands in the on-chain + // `amount` / `attached_value` arg. + amountRaw: sourceRawAmount, + }, + isOpen: isReviewModalOpen, + onWrapSuccess, + onClose: closeReviewModal, + }); + + // ...render form + review modal using the above +} +``` + +- `useWrapTokens` owns the form: direction toggle, amount entry, balance/fee validation + (`isInsufficientCsprForFees`), and fiat display for the source leg. +- `useReviewWrap` drives the review-modal build+sign step: `confirmWrap` calls + `wrapFlowRunner.start({ direction, rawAmount: sourceToken.amountRaw })`, which builds via + `dexContractRepository.buildWrapTransaction`/`buildUnwrapTransaction` (direction-dispatched) + and submits through the signer baked into the runner (step 3). `onWrapSuccess` fires as soon + as the flow's `'wrap:confirmed'` event arrives, not on modal close. A cancelled signature + resets `status` back to `'idle'` (the `'confirm'` step); a submission or on-chain failure sets + `status` to `'error'` with a message in `error`. Either way pressing Confirm again starts a new + flow, the same as swap — there is no reset to call. `ledgerEvent` carries device prompts and + should be rendered here exactly as on the swap side; without it the modal sits at signing with + nothing explaining the wait. As with swap, closing the modal unsubscribes but never cancels a + submitted wrap — see "The flow handle and cancellation semantics" in step 3. + +For swap (approval-then-swap, with slippage/deadline as consumer-owned parameters — see +"Slippage and deadline" above), the equivalent entry points are `useSwapTokens` (form +orchestration) and `useReviewSwap` (review modal, approval + swap). + +Every hook shown in this guide takes a single object parameter whose dependency fields +(`network`, `activePublicKey`, `swapRepository`, `dexContractRepository`, `tokensRepository`, +`swapFlowRunner`, `wrapFlowRunner`) are required — there is no default or optional fallback for +them, and each hook's params type `Pick`s only the subset it needs from `ISwapDependencies`. + +## Further reading + +- `src/domain/flows/entities.ts` — `IFlowHandle`, `ISwapFlowRunner`/`IWrapFlowRunner`, + `SwapFlowEvent`/`WrapFlowEvent`, `ISwapFlowResult`/`IWrapFlowResult` (re-exported from + `src/domain/index.ts`); `src/data/flows/` — `createSwapFlowRunner`, `createWrapFlowRunner`, + `createFlowHandle` (root-exported). `src/react/types.ts` — `ISwapDependencies`. +- `src/domain/swap/`, `src/domain/dex/` — entities, repository interfaces, errors. +- `src/domain/transactionStatus/` — `ITransactionStatusRepository`, `ITransactionOutcome`, + `TransactionTimeoutError`. +- `src/domain/constants/config.ts` — fee/slippage/deadline constants and DEX gas amounts; + `src/domain/constants/casperNetwork.ts` — the per-network trade API url and contract package + hashes. +- `src/react/hooks/` — `ui/` (debounce, modal state), `api/` (TanStack Query hooks over + `swapRepository`/`dexContractRepository`), `token/` (balance, fee validation, pair-state + helpers shared by swap and wrap), `swap/`, `wrap/` (the page-level form hooks plus + `useReviewSwap`/`useReviewWrap`, which subscribe to the flow runners in `src/data/flows`). diff --git a/eslint.config.js b/eslint.config.js index 881d03a..de946a0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,4 +28,30 @@ module.exports = [ 'jest/no-disabled-tests': 'off', }, }, + { + // Both rules are scoped to the swap surface. Repo-wide, the unbound-catch rule would + // report 23 pre-existing sites and the type-aware rule a larger backlog still; these are + // the directories where a swallowed error or a truthiness test on a nullable number + // decides what a user signs. + files: ['src/react/**/*.{ts,tsx}', 'src/data/repositories/dex/**/*.ts'], + languageOptions: { + parserOptions: { project: './tsconfig.json', tsconfigRootDir: __dirname }, + }, + rules: { + // Nullable strings stay allowed: `!activePublicKey` and `!packageHash` are the repo's + // idiom for "absent", where '' and null mean the same thing. + '@typescript-eslint/strict-boolean-expressions': ['error', { allowNullableString: true }], + // `catch {}` discards the error, so nothing can log, classify or re-throw it. Binding it + // is a speed bump, not a guarantee — a deliberate swallow disables this rule with a + // reason, which is the point. + 'no-restricted-syntax': [ + 'error', + { + selector: 'CatchClause[param=null]', + message: + 'Bind the caught error (`catch (e)`). If the swallow is deliberate, disable this rule on the line with a reason.', + }, + ], + }, + }, ]; diff --git a/index.ts b/index.ts index e571ffb..997235b 100644 --- a/index.ts +++ b/index.ts @@ -2,10 +2,14 @@ export * from './src/utils'; export * from './src/setup'; export * from './src/domain'; export * from './src/typings'; -// The two SDK-backed util modules. They are re-exported here rather than from the `utils` / -// `casperSdk` / `eip712` barrels, because those are reached from most of `src/data` — a -// re-export there made every DTO a transitive `casper-js-sdk` importer. The package root already -// links the SDK through `./src/setup`, so nothing is lost by exporting them here, and the public -// API is exactly what it was (WALLET-1421). +// The SDK-backed util modules are exported here rather than from the `utils` / `casperSdk` / +// `eip712` barrels: those are reached from most of `src/data`, where a re-export would make every +// DTO a transitive `casper-js-sdk` importer. The package root already links the SDK through +// `./src/setup`, so it costs nothing here. export * from './src/utils/casperSdk/cep-nft-transfer'; export * from './src/utils/eip712/sign'; +export * from './src/utils/casperSdk/validation'; +export * from './src/data/signers'; +export * from './src/utils/casperSdk/tx-builders'; +export * from './src/data/ledger'; +export * from './src/data/flows'; diff --git a/jest.config.cjs b/jest.config.cjs index 0af4ec4..4ce12b1 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -49,9 +49,14 @@ const config = { moduleFileExtensions: ['ts', 'tsx', 'mjs', 'js', 'jsx', 'json'], // Whitelist ESM-only transitive deps that need transforming. - transformIgnorePatterns: ['node_modules/(?!(@noble|@scure|nanoid|jose|ws|@bufbuild)/)'], + // `dom-accessibility-api` (via @testing-library/dom) ships its TypeScript sources, and + // `moduleFileExtensions` resolves `.ts` first — so Jest reaches the sources, not the build, + // and they have to be transformed like our own code. + transformIgnorePatterns: [ + 'node_modules/(?!(@noble|@scure|nanoid|jose|ws|@bufbuild|dom-accessibility-api)/)', + ], - testMatch: ['/src/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts?(x)'], collectCoverageFrom: [ 'src/**/*.ts', @@ -67,6 +72,13 @@ const config = { coverageDirectory: 'coverage', coverageReporters: ['text-summary', 'lcov', 'html'], + // Each worker builds its own ts-jest TypeScript program, so the default + // (cores - 1) makes two concurrent runs oversubscribe the machine into swap. + maxWorkers: '50%', + // 15s, not jest's 5s: under a loaded machine the slower suites exceed 5s and + // report as failures, which is indistinguishable from a real regression. + testTimeout: 15000, + clearMocks: true, restoreMocks: true, }; diff --git a/package.json b/package.json index 0898e39..3559f08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CasperWalletCore", - "version": "1.4.0", + "version": "2.0.0", "description": "Core business logic and data layer for the Casper Wallet browser extension and mobile app.", "license": "Apache-2.0", "author": "MAKE Software", @@ -38,29 +38,62 @@ "@casper-ecosystem/casper-eip-712": "1.2.1", "@noble/hashes": "^1.8.0", "apisauce": "^3.2.2", - "casper-js-sdk": "5.1.0", + "casper-js-sdk": "5.1.1", "date-fns": "^4.4.0", "decimal.js": "^10.6.0", "deepmerge": "^4.3.1", "lru-cache": "11.5.2", + "rxjs": "^7.8.2", "uuid": "^14.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^9.39.5", + "@ledgerhq/hw-transport": "^6.35.4", "@react-native/eslint-config": "^0.87.1", + "@tanstack/react-query": "5.90.6", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.3", + "@types/big.js": "^6.2.2", "@types/jest": "^29.5.14", "@types/node": "^22.20.1", + "@types/react": "^18.3.0", + "@types/react-dom": "^18", + "@zondax/ledger-casper": "^2.6.4", + "big.js": "^7.0.1", "eslint": "^9.39.5", "eslint-plugin-prettier": "^5.5.6", "fast-check": "^4.9.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-environment-jsdom": "29.7.0", "lint-staged": "^17.4.1", "prettier": "^3.9.5", + "react": "18.3.1", + "react-dom": "18.3.1", "ts-jest": "^29.4.12", "typescript": "^5.9.3" }, + "peerDependencies": { + "@ledgerhq/hw-transport": "^6.35.4", + "@tanstack/react-query": "^5", + "@zondax/ledger-casper": "^2.6.4", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@ledgerhq/hw-transport": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + }, + "@zondax/ledger-casper": { + "optional": true + }, + "react": { + "optional": true + } + }, "resolutions": { "axios": "^1.19.0", "form-data": "^4.0.6", diff --git a/src/__test-utils__/render-hook.tsx b/src/__test-utils__/render-hook.tsx new file mode 100644 index 0000000..7ba487f --- /dev/null +++ b/src/__test-utils__/render-hook.tsx @@ -0,0 +1,43 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; + +import type { IDexContractRepository } from '../domain/dex'; +import type { ISwapRepository } from '../domain/swap'; +import type { ITokensRepository } from '../domain/tokens'; + +export const TEST_PUBLIC_KEY = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; + +export const stubTokensRepository = (over: Partial = {}): ITokensRepository => + over as ITokensRepository; + +export const stubSwapRepository = (over: Partial = {}): ISwapRepository => + over as ISwapRepository; + +export const stubDexContractRepository = ( + over: Partial = {}, +): IDexContractRepository => over as IDexContractRepository; + +export interface IRenderHookOptions { + initialProps?: TProps; + queryClient?: QueryClient; +} + +/** + * Renders a hook under a bare `QueryClientProvider`. + * + * Retries are disabled: a hook that rejects should surface that on the first attempt rather + * than making the test wait out the production backoff schedule. + */ +export const renderHookWithQueryClient = ( + hook: (props: TProps) => TResult, + { initialProps, queryClient }: IRenderHookOptions = {}, +) => { + const client = + queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + + const wrapper = ({ children }: React.PropsWithChildren) => + React.createElement(QueryClientProvider, { client }, children); + + return renderHook(hook, { wrapper, initialProps: initialProps as TProps }); +}; diff --git a/src/data/dto/appEvents.test.ts b/src/data/dto/appEvents.test.ts index 44fbeee..af3ef43 100644 --- a/src/data/dto/appEvents.test.ts +++ b/src/data/dto/appEvents.test.ts @@ -9,7 +9,7 @@ describe('AppMarketingEventDto', () => { expect(dto.endAt).toBe('2099-01-01T00:00:00.000Z'); expect(dto.startAt).toBe('2024-01-01T00:00:00.000Z'); expect(dto.url).toBe('https://example.com/promo'); - expect(dto.image_url).toBeNull(); + expect(dto.imageUrl).toBeNull(); }); it('falls back to defaults', () => { diff --git a/src/data/dto/appEvents.ts b/src/data/dto/appEvents.ts index e1973b3..927f945 100644 --- a/src/data/dto/appEvents.ts +++ b/src/data/dto/appEvents.ts @@ -23,7 +23,7 @@ export class AppMarketingEventDto implements IAppMarketingEvent { this.endAt = apiEvent?.end_at ?? null; this.startAt = apiEvent?.start_at ?? ''; this.url = apiEvent?.url ?? ''; - this.image_url = apiEvent?.image_url ?? null; + this.imageUrl = apiEvent?.image_url ?? null; } readonly id: number; @@ -32,5 +32,5 @@ export class AppMarketingEventDto implements IAppMarketingEvent { readonly endAt: string | null; readonly startAt: string; readonly url: string; - readonly image_url: string | null; + readonly imageUrl: string | null; } diff --git a/src/data/dto/index.ts b/src/data/dto/index.ts index 9b2155f..93e1b4b 100644 --- a/src/data/dto/index.ts +++ b/src/data/dto/index.ts @@ -8,3 +8,4 @@ export * from './appEvents'; export * from './txSignatureRequest'; export * from './contractPackage'; export * from './eip712'; +export * from './swap'; diff --git a/src/data/dto/nfts.ts b/src/data/dto/nfts.ts index b19dc6a..3ec2439 100644 --- a/src/data/dto/nfts.ts +++ b/src/data/dto/nfts.ts @@ -24,7 +24,7 @@ export class NftDto implements INft { this.contractPackageHash = apiNft?.contract_package_hash ?? ''; this.contractPackageIcon = apiNft?.contract_package?.icon_url ?? null; this.contactName = apiNft?.contract_package?.name ?? ''; - this.owner_reverse_lookup_mode = Boolean( + this.ownerReverseLookupMode = Boolean( apiNft?.contract_package?.metadata?.owner_reverse_lookup_mode ?? false, ); this.timestamp = apiNft?.timestamp ?? ''; @@ -42,7 +42,7 @@ export class NftDto implements INft { contractPackageHash: string; contractPackageIcon: Maybe; contactName: string; - owner_reverse_lookup_mode: boolean; + ownerReverseLookupMode: boolean; timestamp: string; metadata: INftMetadata; previewUrl: Maybe; diff --git a/src/data/dto/onRamp.test.ts b/src/data/dto/onRamp.test.ts index 8169baa..b89be11 100644 --- a/src/data/dto/onRamp.test.ts +++ b/src/data/dto/onRamp.test.ts @@ -20,6 +20,18 @@ describe('OnRampDto', () => { expect(dto.defaultAmount).toBe('100'); }); + it('maps currency type_id to typeId', () => { + const dto = new OnRampDto({ + countries: [], + defaultCountry: 'US', + currencies: [{ id: 1, code: 'USD', type_id: 'fiat', rate: 1.5 }], + defaultCurrency: 'USD', + defaultAmount: '100', + }); + + expect(dto.currencies).toEqual([{ id: 1, code: 'USD', typeId: 'fiat', rate: 1.5 }]); + }); + it('falls back to empty defaults', () => { const dto = new OnRampDto(); expect(dto.countries).toEqual([]); @@ -49,6 +61,20 @@ describe('OnRampProvidersDto', () => { expect(dto.cryptoCurrency).toBe('CSPR'); }); + it('maps the selected currency type_id to typeId', () => { + const dto = new OnRampProvidersDto({ + availableProviders: [], + currencies: [{ id: 1, code: 'USD', type_id: 'fiat', rate: 1.5 }], + fiatAmount: 100, + cryptoAmount: 1000, + cryptoCurrency: 'CSPR', + isCryptoChanged: false, + fiatCurrency: 'USD', + }); + + expect(dto.currency).toEqual({ id: 1, code: 'USD', typeId: 'fiat', rate: 1.5 }); + }); + it('falls back to defaults', () => { const dto = new OnRampProvidersDto(); expect(dto.availableProviders).toEqual([]); diff --git a/src/data/dto/onRamp.ts b/src/data/dto/onRamp.ts index a176456..c75a9cd 100644 --- a/src/data/dto/onRamp.ts +++ b/src/data/dto/onRamp.ts @@ -7,6 +7,7 @@ import { } from '../../domain'; import type { IGetOnRampResponse, + IOnRampCurrencyItemResponse, IOnRampProvidersResponse, IResponseCountry, } from '../repositories'; @@ -17,7 +18,7 @@ export class OnRampDto implements IOnRampOptions { this.countries = mapCountriesWithFlags(response?.countries); this.defaultAmount = response?.defaultAmount ?? ''; this.defaultCountry = response?.defaultCountry ?? ''; - this.currencies = response?.currencies ?? []; + this.currencies = mapCurrencies(response?.currencies); this.defaultCurrency = response?.defaultCurrency ?? ''; } @@ -32,7 +33,7 @@ export class OnRampProvidersDto implements IOnRampProvidersOptions { constructor(response?: IOnRampProvidersResponse) { this.fiatAmount = response?.fiatAmount ?? 0; this.cryptoAmount = response?.cryptoAmount ?? 0; - this.currency = response?.currencies?.[0] ?? null; + this.currency = mapCurrencies(response?.currencies)[0] ?? null; this.availableProviders = response?.availableProviders ?? []; this.isCryptoChanged = response?.isCryptoChanged ?? false; this.cryptoCurrency = response?.cryptoCurrency ?? ''; @@ -46,6 +47,9 @@ export class OnRampProvidersDto implements IOnRampProvidersOptions { readonly cryptoCurrency: string; } +const mapCurrencies = (currencies?: IOnRampCurrencyItemResponse[]): IOnRampCurrencyItem[] => + (currencies ?? []).map(({ id, code, type_id, rate }) => ({ id, code, typeId: type_id, rate })); + // TODO fix it export const mapCountriesWithFlags = (countries?: IResponseCountry[]): IOnRampCountry[] => { if (!countries) { diff --git a/src/data/dto/swap/index.ts b/src/data/dto/swap/index.ts new file mode 100644 index 0000000..6774447 --- /dev/null +++ b/src/data/dto/swap/index.ts @@ -0,0 +1,78 @@ +import type { IDexToken, ISwapQuote, SwapQuoteType } from '../../../domain'; +import { CSPR_COIN, CSPR_NATIVE_TOKEN_ID } from '../../../domain'; +import type { Maybe } from '../../../typings'; +import { calculateSwapRate, getDecimalTokenBalance } from '../../../utils'; +import type { DexTokenApiResponse, RawSwapQuote } from '../../repositories/swap/types'; +import { getPreferredTokenMarketData } from '../common'; + +/** + * Maps the WCSPR API record to a synthetic native token while keeping the real on-chain + * `packageHash` — this is what makes `token.id === CSPR_NATIVE_TOKEN_ID` the native-token check + * everywhere else in the swap domain. + */ +export class DexTokenDto implements IDexToken { + constructor(resp: DexTokenApiResponse, wrappedCsprPackageHash: string) { + const { contract_package: contractPackage, contract_package_hash: contractPackageHash } = resp; + const isWrappedCspr = contractPackageHash === wrappedCsprPackageHash; + const tokenMarketData = getPreferredTokenMarketData(contractPackage.token_market_data); + + this.id = isWrappedCspr ? CSPR_NATIVE_TOKEN_ID : contractPackageHash; + this.name = isWrappedCspr ? CSPR_COIN.name : contractPackage.metadata.name; + this.symbol = isWrappedCspr ? CSPR_COIN.symbol : contractPackage.metadata.symbol; + this.icon = contractPackage.icon_url; + this.decimals = contractPackage.metadata.decimals; + this.packageHash = contractPackageHash; + this.isWhitelisted = resp.is_whitelisted; + this.isBlacklisted = resp.is_blacklisted; + this.fiatRates = tokenMarketData?.latest_rate ?? null; + this.totalValueLocked = resp.total_value_locked ?? null; + this.volume24h = tokenMarketData?.volume_24h ?? null; + } + + readonly id: string; + readonly name: string; + readonly symbol: string; + readonly icon: Maybe; + readonly decimals: number; + readonly packageHash: string; + readonly isWhitelisted: boolean; + readonly isBlacklisted: boolean; + readonly fiatRates: Maybe; + readonly totalValueLocked: Maybe; + readonly volume24h: Maybe; +} + +export class SwapQuoteDto implements ISwapQuote { + constructor(resp: RawSwapQuote, tokenIn: IDexToken, tokenOut: IDexToken, typeId: SwapQuoteType) { + this.amountIn = resp.amount_in; + this.amountOut = resp.amount_out; + this.executionPrice = resp.execution_price; + this.midPrice = resp.mid_price; + this.path = resp.path; + this.priceImpact = resp.price_impact; + this.recommendedSlippageBps = resp.recommended_slippage_bps; + this.typeId = typeId; + + this.amountInDecimal = getDecimalTokenBalance(resp.amount_in, tokenIn.decimals, '0'); + this.amountOutDecimal = getDecimalTokenBalance(resp.amount_out, tokenOut.decimals, '0'); + this.rate = calculateSwapRate( + resp.amount_in, + tokenIn.decimals, + resp.amount_out, + tokenOut.decimals, + typeId, + ); + } + + readonly amountIn: string; + readonly amountOut: string; + readonly executionPrice: string; + readonly midPrice: string; + readonly path: string[]; + readonly priceImpact: string; + readonly recommendedSlippageBps: string; + readonly typeId: SwapQuoteType; + readonly amountInDecimal: string; + readonly amountOutDecimal: string; + readonly rate: string; +} diff --git a/src/data/dto/validators.ts b/src/data/dto/validators.ts index 92a9a08..7bb6794 100644 --- a/src/data/dto/validators.ts +++ b/src/data/dto/validators.ts @@ -16,9 +16,9 @@ import Decimal from 'decimal.js'; * declared locally. * * Importing them from `casper-js-sdk` links its whole ~900 KB prebuilt UMD bundle — for two - * numeric constants — and `ValidatorDto` is reachable from a wallet's validator list, which is - * one of the surfaces WALLET-1421 is keeping SDK-free. `validators.test.ts` asserts these stay - * equal to the SDK's, so a change upstream fails the build here rather than silently drifting. + * numeric constants — and `ValidatorDto` is reachable from a wallet's validator list, one of the + * surfaces that must stay SDK-free. `validators.test.ts` asserts these stay equal to the SDK's, + * so a change upstream fails the build here rather than silently drifting. */ const DEFAULT_MINIMUM_DELEGATION_AMOUNT = BigInt(500) * BigInt(1_000_000_000); const DEFAULT_MAXIMUM_DELEGATION_AMOUNT = BigInt(1_000_000_000) * BigInt(1_000_000_000); diff --git a/src/data/flows/index.ts b/src/data/flows/index.ts new file mode 100644 index 0000000..3ef608c --- /dev/null +++ b/src/data/flows/index.ts @@ -0,0 +1,3 @@ +export * from './runner'; +export * from './swapFlow'; +export * from './wrapFlow'; diff --git a/src/data/flows/runner.test.ts b/src/data/flows/runner.test.ts new file mode 100644 index 0000000..c4a17de --- /dev/null +++ b/src/data/flows/runner.test.ts @@ -0,0 +1,58 @@ +import { Subject, firstValueFrom, tap, toArray } from 'rxjs'; + +import { createFlowHandle } from './runner'; + +type Event = { type: string; error?: unknown }; + +const makeHandle = ( + generator: (signal: AbortSignal) => AsyncGenerator, + sideEvents$?: Subject, +) => + createFlowHandle({ + id: 'flow-1', + generator, + sideEvents$, + toFailureEvent: error => ({ type: 'failed', error }), + toResult: events => { + const failed = events.find(e => e.type === 'failed'); + + return failed ? { status: 'failed', error: failed.error } : { status: 'success' }; + }, + }); + +describe('createFlowHandle', () => { + it('turns a throw out of the generator into the flow’s last event', async () => { + const boom = new Error('boom'); + const errored = jest.fn(); + const handle = makeHandle(async function* () { + yield { type: 'started' }; + + throw boom; + }); + + const events = await firstValueFrom(handle.events$.pipe(toArray(), tap({ error: errored }))); + + expect(events).toEqual([{ type: 'started' }, { type: 'failed', error: boom }]); + expect(errored).not.toHaveBeenCalled(); + }); + + it('resolves done for a generator that throws before its first yield', async () => { + const handle = makeHandle(async function* () { + throw new Error('pre-flight'); + }); + + await expect(handle.done).resolves.toMatchObject({ status: 'failed' }); + }); + + it('replays the converted failure to a late subscriber', async () => { + const handle = makeHandle(async function* () { + throw new Error('boom'); + }); + + await handle.done; + + await expect(firstValueFrom(handle.events$.pipe(toArray()))).resolves.toEqual([ + { type: 'failed', error: expect.any(Error) }, + ]); + }); +}); diff --git a/src/data/flows/runner.ts b/src/data/flows/runner.ts new file mode 100644 index 0000000..9920713 --- /dev/null +++ b/src/data/flows/runner.ts @@ -0,0 +1,71 @@ +import { + catchError, + from, + merge, + of, + shareReplay, + toArray, + firstValueFrom, + takeUntil, + Subject, +} from 'rxjs'; +import type { Observable } from 'rxjs'; + +import type { IFlowHandle } from '../../domain/flows'; + +export interface ICreateFlowHandleParams { + id: string; + /** The flow itself. Receives the abort signal for explicit cancellation. */ + generator: (signal: AbortSignal) => AsyncGenerator; + /** Merged into the event stream; stops when the flow completes. */ + sideEvents$?: Observable; + /** Terminal event for a throw the generator did not turn into an event itself. */ + toFailureEvent: (error: unknown) => TEvent; + /** Folds the full event sequence into the flow's terminal result. */ + toResult: (events: TEvent[]) => TResult; +} + +/** + * Lifts a flow generator into a hot, replayed handle. + * + * The flow starts once and runs regardless of who is subscribed; a later subscriber replays the + * whole history. Unsubscribing never cancels — only `cancel()` does. A throw out of the generator + * becomes `toFailureEvent(error)`, so `events$` never errors and `done` always resolves. + */ +export const createFlowHandle = ({ + id, + generator, + sideEvents$, + toFailureEvent, + toResult, +}: ICreateFlowHandleParams): IFlowHandle => { + const controller = new AbortController(); + const finished$ = new Subject(); + + // `done` is derived from this stream alone, never from the merged `events$`: with a live + // `sideEvents$` that never completes on its own, `events$` would only complete once `finished$` + // fires, and `finished$` only fires once `done` settles. + const flow$ = from(generator(controller.signal)).pipe( + catchError((error: unknown) => of(toFailureEvent(error))), + shareReplay({ bufferSize: Infinity, refCount: false }), + ); + + const events$ = (sideEvents$ ? merge(flow$, sideEvents$.pipe(takeUntil(finished$))) : flow$).pipe( + shareReplay({ bufferSize: Infinity, refCount: false }), + ); + + // Subscribing here is what makes both streams hot: the flow runs and side events land in the + // replay buffer whether or not anyone else is listening. + events$.subscribe({ error: () => undefined }); + + const done = firstValueFrom(flow$.pipe(toArray()), { defaultValue: [] as TEvent[] }) + .then(toResult) + .finally(() => finished$.next()); + + return { + id, + events$, + done, + cancel: () => controller.abort(), + }; +}; diff --git a/src/data/flows/swapFlow.test.ts b/src/data/flows/swapFlow.test.ts new file mode 100644 index 0000000..6c8a8f5 --- /dev/null +++ b/src/data/flows/swapFlow.test.ts @@ -0,0 +1,499 @@ +import { Subject, firstValueFrom, tap, toArray } from 'rxjs'; + +import { createSwapFlowRunner } from './swapFlow'; + +import { calculateApprovalAmount, calculateMaxAmountWithSlippage } from '../../utils/amounts'; + +import type { ITransactionOutcome } from '../../domain/transactionStatus'; + +import { stubDexContractRepository, TEST_PUBLIC_KEY } from '../../__test-utils__/render-hook'; +import { CSPR_NATIVE_TOKEN_ID } from '../../domain/constants'; +import { + TransactionTimeoutError, + TransactionWatchCancelledError, +} from '../../domain/transactionStatus'; +import { SwapQuoteType } from '../../domain/swap'; +import { LedgerError, LedgerEventStatus } from '../../domain/ledger'; +import type { ILedgerEvent } from '../../domain/ledger'; +import type { IDexTokenWithAmount } from '../../domain/swap'; +import type { ISwapFlowDeps } from './swapFlow'; + +const token = (id: string): IDexTokenWithAmount => + ({ + id, + packageHash: `${id}-hash`, + decimals: 9, + amountRaw: '1000000000', + amountFormatted: '1', + }) as IDexTokenWithAmount; + +const BUILT_APPROVAL = { kind: 'approval', transaction: {} } as never; +const BUILT_SWAP = { kind: 'swap', transaction: {} } as never; +/** The legacy-Deploy artifact: `isDeploy` is derived from the presence of this field. */ +const BUILT_SWAP_DEPLOY = { kind: 'swap', deploy: {} } as never; + +const outcome = ( + hash: string, + status: 'success' | 'failure', + errorMessage?: string, +): ITransactionOutcome => + status === 'failure' + ? { hash, status, blockHeight: 1, errorMessage } + : { hash, status, blockHeight: 1 }; + +const makeDeps = (over: Partial = {}): ISwapFlowDeps => ({ + network: 'testnet', + publicKey: TEST_PUBLIC_KEY, + signer: { publicKeyHex: TEST_PUBLIC_KEY } as never, + supportsTransactionV1: true, + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(false), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction: jest.fn().mockResolvedValue(BUILT_SWAP), + }), + casperTransactionsRepository: { + sendDexTransaction: jest.fn().mockResolvedValue('0xswap'), + }, + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => outcome(hash, 'success')), + }, + ...over, +}); + +const startParams = (over = {}) => ({ + firstToken: token('cep18-in'), + secondToken: token('cep18-out'), + path: ['cep18-in', 'cep18-out'], + quoteType: SwapQuoteType.ExactIn, + slippage: 1, + deadline: 20, + ...over, +}); + +const collect = async (deps: ISwapFlowDeps, params = startParams()) => { + const handle = createSwapFlowRunner(deps).start(params); + const events = await firstValueFrom(handle.events$.pipe(toArray())); + + return { handle, events, types: events.map(e => e.type), result: await handle.done }; +}; + +describe('createSwapFlowRunner', () => { + it('skips the approval leg for a native CSPR input', async () => { + const deps = makeDeps(); + const { types } = await collect(deps, startParams({ firstToken: token(CSPR_NATIVE_TOKEN_ID) })); + + expect(types).toEqual([ + 'approval:checking', + 'approval:not-required', + 'swap:signing', + 'swap:sent', + 'swap:confirmed', + ]); + expect(deps.dexContractRepository.buildApprovalTransaction).not.toHaveBeenCalled(); + }); + + it('skips the approval leg when the allowance already covers the trade', async () => { + const deps = makeDeps(); + const { types } = await collect(deps); + + expect(types).toEqual([ + 'approval:checking', + 'approval:not-required', + 'swap:signing', + 'swap:sent', + 'swap:confirmed', + ]); + }); + + it('runs approval then swap, in that order, when an approval is required', async () => { + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(true), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction: jest.fn().mockResolvedValue(BUILT_SWAP), + }), + }); + + const { types } = await collect(deps); + + expect(types).toEqual([ + 'approval:checking', + 'approval:signing', + 'approval:sent', + 'approval:confirmed', + 'swap:signing', + 'swap:sent', + 'swap:confirmed', + ]); + }); + + it('does not build the swap until the approval has settled on chain', async () => { + const order: string[] = []; + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(true), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction: jest.fn(async () => { + order.push('build-swap'); + + return BUILT_SWAP; + }), + }), + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => { + order.push('settled'); + + return outcome(hash, 'success'); + }), + }, + }); + + await collect(deps); + + expect(order[0]).toBe('settled'); + expect(order).toContain('build-swap'); + expect(order.indexOf('settled')).toBeLessThan(order.indexOf('build-swap')); + }); + + it('derives the approval grant from the same amount the check was made against', async () => { + const checkApprovalRequired = jest.fn().mockResolvedValue(true); + const buildApprovalTransaction = jest.fn().mockResolvedValue(BUILT_APPROVAL); + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired, + buildApprovalTransaction, + buildSwapTransaction: jest.fn().mockResolvedValue(BUILT_SWAP), + }), + }); + + await collect(deps); + + const { requiredAmount } = checkApprovalRequired.mock.calls[0][0]; + const { amount } = buildApprovalTransaction.mock.calls[0][0]; + const expectedRequired = calculateMaxAmountWithSlippage('1000000000', 1); + + expect(requiredAmount).toBe(expectedRequired); + expect(amount).toBe(calculateApprovalAmount(expectedRequired)); + }); + + it('stops before the swap when the approval submission fails', async () => { + const buildSwapTransaction = jest.fn().mockResolvedValue(BUILT_SWAP); + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(true), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction, + }), + casperTransactionsRepository: { + sendDexTransaction: jest.fn().mockRejectedValue(new Error('signing refused')), + }, + }); + + const { types, result } = await collect(deps); + + expect(types).toEqual(['approval:checking', 'approval:signing', 'failed']); + expect(result.status).toBe('failed'); + expect(buildSwapTransaction).not.toHaveBeenCalled(); + }); + + it('stops before the swap when the approval reverts on chain', async () => { + const buildSwapTransaction = jest.fn().mockResolvedValue(BUILT_SWAP); + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(true), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction, + }), + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => + outcome(hash, 'failure', 'User error: 1'), + ), + }, + }); + + const { events, result } = await collect(deps); + const failed = events.find(e => e.type === 'failed'); + + expect(failed).toMatchObject({ leg: 'approval' }); + expect(result.status).toBe('failed'); + expect(buildSwapTransaction).not.toHaveBeenCalled(); + }); + + it('tells the settlement watch which artifact was submitted', async () => { + const waitForTransaction = jest.fn(async ({ hash }: { hash: string }) => + outcome(hash, 'success'), + ); + const transactionStatusRepository = { observeTransaction: jest.fn(), waitForTransaction }; + + await collect(makeDeps({ transactionStatusRepository })); + + expect(waitForTransaction).toHaveBeenCalledWith(expect.objectContaining({ isDeploy: false })); + + waitForTransaction.mockClear(); + + await collect( + makeDeps({ + transactionStatusRepository, + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn().mockResolvedValue(false), + buildSwapTransaction: jest.fn().mockResolvedValue(BUILT_SWAP_DEPLOY), + }), + }), + ); + + expect(waitForTransaction).toHaveBeenCalledWith(expect.objectContaining({ isDeploy: true })); + }); + + it('reports a reverted swap as a swap-leg failure carrying the node error', async () => { + const deps = makeDeps({ + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => + outcome(hash, 'failure', 'User error: 65534'), + ), + }, + }); + + const { events, result } = await collect(deps); + const failed = events.find(e => e.type === 'failed') as { leg: string; error: unknown }; + + expect(failed.leg).toBe('swap'); + expect(String((failed.error as Error).message)).toContain('65534'); + expect(result.status).toBe('failed'); + }); + + it('reports a settlement timeout as a failure, never as a confirmation', async () => { + const deps = makeDeps({ + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn().mockRejectedValue(new TransactionTimeoutError('0xswap')), + }, + }); + + const { types, result } = await collect(deps); + + expect(types).not.toContain('swap:confirmed'); + expect(types).toContain('failed'); + expect(result.status).toBe('failed'); + }); + + it('cancels before submitting anything when cancel lands during the approval check', async () => { + let releaseCheck: () => void = () => undefined; + const sendDexTransaction = jest.fn().mockResolvedValue('0xswap'); + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + checkApprovalRequired: jest.fn( + () => new Promise(resolve => (releaseCheck = () => resolve(true))), + ), + buildApprovalTransaction: jest.fn().mockResolvedValue(BUILT_APPROVAL), + buildSwapTransaction: jest.fn().mockResolvedValue(BUILT_SWAP), + }), + casperTransactionsRepository: { sendDexTransaction }, + }); + + const handle = createSwapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + handle.cancel(); + releaseCheck(); + + expect((await events).map(e => e.type)).toContain('cancelled'); + expect(sendDexTransaction).not.toHaveBeenCalled(); + expect((await handle.done).status).toBe('cancelled'); + }); + + it('keeps the submitted hash when cancelled while awaiting settlement', async () => { + let releaseSettlement: () => void = () => undefined; + const deps = makeDeps({ + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn( + () => + new Promise( + resolve => (releaseSettlement = () => resolve(outcome('0xswap', 'success'))), + ), + ), + }, + }); + + const handle = createSwapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + await new Promise(resolve => setTimeout(resolve, 10)); + handle.cancel(); + releaseSettlement(); + + const result = await handle.done; + + expect((await events).map(e => e.type)).toContain('cancelled'); + expect(result.status).toBe('cancelled'); + expect(result.swapHash).toBe('0xswap'); + }); + + it('hands the abort signal to the settlement watch and reports its abort as a cancellation', async () => { + const waitForTransaction = jest.fn( + ({ signal }: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => + signal?.addEventListener( + 'abort', + () => reject(new TransactionWatchCancelledError('0xswap')), + { once: true }, + ), + ), + ); + const deps = makeDeps({ + transactionStatusRepository: { observeTransaction: jest.fn(), waitForTransaction }, + }); + + const handle = createSwapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + await new Promise(resolve => setTimeout(resolve, 10)); + handle.cancel(); + + const result = await handle.done; + const types = (await events).map(e => e.type); + + expect(waitForTransaction).toHaveBeenCalledWith( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(types).toContain('cancelled'); + expect(types).not.toContain('failed'); + expect(result.status).toBe('cancelled'); + }); + + it('keeps running after every subscriber has unsubscribed', async () => { + const deps = makeDeps(); + const handle = createSwapFlowRunner(deps).start(startParams()); + + const subscription = handle.events$.subscribe(); + subscription.unsubscribe(); + + const result = await handle.done; + + expect(result.status).toBe('success'); + expect(deps.casperTransactionsRepository.sendDexTransaction).toHaveBeenCalledTimes(1); + }); + + it('replays the whole history to a subscriber that arrives after completion', async () => { + const handle = createSwapFlowRunner(makeDeps()).start(startParams()); + + await handle.done; + + const replayed = await firstValueFrom(handle.events$.pipe(toArray())); + + expect(replayed.map(e => e.type)).toEqual([ + 'approval:checking', + 'approval:not-required', + 'swap:signing', + 'swap:sent', + 'swap:confirmed', + ]); + }); + + it('serves two subscribers from one flow without duplicating any work', async () => { + const deps = makeDeps(); + const handle = createSwapFlowRunner(deps).start(startParams()); + + const [a, b] = await Promise.all([ + firstValueFrom(handle.events$.pipe(toArray())), + firstValueFrom(handle.events$.pipe(toArray())), + ]); + + expect(a.map(e => e.type)).toEqual(b.map(e => e.type)); + expect(deps.casperTransactionsRepository.sendDexTransaction).toHaveBeenCalledTimes(1); + }); + + it('returns the same handle from getActive while the flow is running', () => { + const runner = createSwapFlowRunner(makeDeps()); + const handle = runner.start(startParams()); + + expect(runner.getActive(handle.id)).toBe(handle); + }); + + it('forgets a finished flow', async () => { + const runner = createSwapFlowRunner(makeDeps()); + const handle = runner.start(startParams()); + + await handle.done; + + expect(runner.getActive(handle.id)).toBeNull(); + }); + + it('completes at submission when settlement is not awaited', async () => { + const deps = makeDeps(); + const { types, result } = await collect(deps, startParams({ awaitSettlement: false })); + + expect(types).toEqual([ + 'approval:checking', + 'approval:not-required', + 'swap:signing', + 'swap:sent', + ]); + expect(result).toMatchObject({ status: 'success', swapHash: '0xswap' }); + expect(result.outcome).toBeUndefined(); + }); + + it('merges ledger device events into the flow stream', async () => { + const ledgerEvents$ = new Subject(); + const deps = makeDeps({ ledgerEvents$ }); + const handle = createSwapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + ledgerEvents$.next({ status: 'waiting-response' } as unknown as ILedgerEvent); + + expect((await events).some(e => e.type === 'ledger')).toBe(true); + }); + + it('completes normally when no ledger stream is supplied', async () => { + const { types } = await collect(makeDeps()); + + expect(types).not.toContain('ledger'); + }); + + it('never rejects from done, whatever went wrong', async () => { + const deps = makeDeps({ + casperTransactionsRepository: { + sendDexTransaction: jest.fn().mockRejectedValue(new Error('boom')), + }, + }); + + const handle = createSwapFlowRunner(deps).start(startParams()); + + await expect(handle.done).resolves.toMatchObject({ status: 'failed' }); + }); + + it('reports an unclamped slippage as a failed approval leg, not a rejected done', async () => { + const deps = makeDeps(); + const errored = jest.fn(); + const handle = createSwapFlowRunner(deps).start(startParams({ slippage: Number.NaN })); + + const events = await firstValueFrom( + handle.events$.pipe(toArray()).pipe(tap({ error: errored })), + ); + + expect(events.map(e => e.type)).toEqual(['failed']); + expect(errored).not.toHaveBeenCalled(); + await expect(handle.done).resolves.toMatchObject({ status: 'failed' }); + expect(deps.dexContractRepository.checkApprovalRequired).not.toHaveBeenCalled(); + }); + + it('classifies an on-device rejection as cancelled without a supplied classifier', async () => { + const deps = makeDeps({ + casperTransactionsRepository: { + sendDexTransaction: jest + .fn() + .mockRejectedValue(new LedgerError({ status: LedgerEventStatus.SignatureCanceled })), + }, + }); + + const { types, result } = await collect(deps); + + expect(types).toContain('cancelled'); + expect(types).not.toContain('failed'); + expect(result.status).toBe('cancelled'); + }); +}); diff --git a/src/data/flows/swapFlow.ts b/src/data/flows/swapFlow.ts new file mode 100644 index 0000000..027e2d7 --- /dev/null +++ b/src/data/flows/swapFlow.ts @@ -0,0 +1,309 @@ +import { map } from 'rxjs'; +import { v4 as uuid } from 'uuid'; +import type { Observable } from 'rxjs'; + +import { createFlowHandle } from './runner'; + +import { calculateApprovalAmount, calculateMaxAmountWithSlippage } from '../../utils/amounts'; +import { CSPR_NATIVE_TOKEN_ID } from '../../domain/constants'; +import { isLedgerSignatureCancelled } from '../../domain/ledger'; +import type { + CasperNetwork, + ICasperSigner, + ICasperTransactionsRepository, + IBuiltDexTransaction, + IDexContractRepository, + ILedgerEvent, + ITransactionOutcome, + ITransactionSuccessOutcome, + ITransactionStatusRepository, + ISwapFlowHandle, + ISwapFlowResult, + ISwapFlowRunner, + IStartSwapFlowParams, + SwapFlowEvent, + SwapLeg, +} from '../../domain'; + +export interface ISwapFlowDeps { + network: CasperNetwork; + publicKey: string; + signer: ICasperSigner; + /** `apiVersion.startsWith('2.')` && (software key ? true : persisted Ledger capability). */ + supportsTransactionV1: boolean; + dexContractRepository: IDexContractRepository; + casperTransactionsRepository: Pick; + transactionStatusRepository: ITransactionStatusRepository; + /** Merged into `events$` so device prompts interleave with flow progress. */ + ledgerEvents$?: Observable; + /** + * Classifies a signing rejection as a user cancellation rather than a failure. + * Default: {@link isLedgerSignatureCancelled}. + */ + isCancellationError?: (error: unknown) => boolean; +} + +const legSigningEvent = (leg: SwapLeg): SwapFlowEvent => + leg === 'approval' ? { type: 'approval:signing' } : { type: 'swap:signing' }; + +const legSentEvent = (leg: SwapLeg, hash: string): SwapFlowEvent => + leg === 'approval' ? { type: 'approval:sent', hash } : { type: 'swap:sent', hash }; + +const legConfirmedEvent = (leg: SwapLeg, outcome: ITransactionSuccessOutcome): SwapFlowEvent => + leg === 'approval' ? { type: 'approval:confirmed' } : { type: 'swap:confirmed', outcome }; + +/** + * Builds, signs and submits one leg (approval or swap), then optionally waits for it to settle. + * Returns `true` when the leg is done and the caller may proceed, `false` when it failed or was + * cancelled — the caller is expected to stop the flow in that case. + */ +const submitLeg = async function* ( + deps: ISwapFlowDeps, + signal: AbortSignal, + leg: SwapLeg, + { awaitSettlement }: { awaitSettlement: boolean }, + build: () => Promise, +): AsyncGenerator { + if (signal.aborted) { + yield { type: 'cancelled', leg }; + + return false; + } + + yield legSigningEvent(leg); + + let built: IBuiltDexTransaction; + let hash: string; + + try { + built = await build(); + hash = await deps.casperTransactionsRepository.sendDexTransaction({ + built, + network: deps.network, + signer: deps.signer, + }); + } catch (error) { + yield (deps.isCancellationError ?? isLedgerSignatureCancelled)(error) + ? { type: 'cancelled', leg } + : { type: 'failed', leg, error }; + + return false; + } + + if (signal.aborted) { + yield { type: 'cancelled', leg }; + + return false; + } + + yield legSentEvent(leg, hash); + + if (!awaitSettlement) { + return true; + } + + let outcome: ITransactionOutcome; + + try { + outcome = await deps.transactionStatusRepository.waitForTransaction({ + hash, + network: deps.network, + isDeploy: built.deploy !== undefined, + signal, + }); + } catch (error) { + yield signal.aborted ? { type: 'cancelled', leg } : { type: 'failed', leg, error }; + + return false; + } + + if (signal.aborted) { + yield { type: 'cancelled', leg }; + + return false; + } + + if (outcome.status === 'failure') { + yield { + type: 'failed', + leg, + error: new Error(`Transaction failed: ${outcome.errorMessage ?? 'unknown reason'}`), + }; + + return false; + } + + yield legConfirmedEvent(leg, outcome); + + return true; +}; + +/** + * The approve-then-swap sequence. The approval leg, when required, is always awaited on chain + * before the swap is built — submitting the swap first spends the payment on a transaction that + * reverts against a not-yet-landed allowance. + */ +const runSwap = async function* ( + deps: ISwapFlowDeps, + params: IStartSwapFlowParams, + signal: AbortSignal, +): AsyncGenerator { + const { + firstToken, + secondToken, + path, + quoteType, + slippage, + deadline, + awaitSettlement = true, + } = params; + const isNative = firstToken.id === CSPR_NATIVE_TOKEN_ID; + + let requiredAmount: string; + let approvalAmount: string; + + try { + requiredAmount = isNative + ? firstToken.amountRaw + : calculateMaxAmountWithSlippage(firstToken.amountRaw, slippage); + + // The grant is derived from the amount the check runs against, so an approval always clears it. + approvalAmount = isNative ? firstToken.amountRaw : calculateApprovalAmount(requiredAmount); + } catch (error) { + yield { type: 'failed', leg: 'approval', error }; + + return; + } + + yield { type: 'approval:checking' }; + + if (signal.aborted) { + yield { type: 'cancelled', leg: 'approval' }; + + return; + } + + let approvalRequired: boolean; + + try { + approvalRequired = + !isNative && + (await deps.dexContractRepository.checkApprovalRequired({ + network: deps.network, + contractPackageHash: firstToken.packageHash, + publicKey: deps.publicKey, + requiredAmount, + })); + } catch (error) { + yield { type: 'failed', leg: 'approval', error }; + + return; + } + + if (signal.aborted) { + yield { type: 'cancelled', leg: 'approval' }; + + return; + } + + if (!approvalRequired) { + yield { type: 'approval:not-required' }; + } else { + const approved = yield* submitLeg(deps, signal, 'approval', { awaitSettlement: true }, () => + deps.dexContractRepository.buildApprovalTransaction({ + network: deps.network, + publicKey: deps.publicKey, + contractPackageHash: firstToken.packageHash, + amount: approvalAmount, + useTransactionV1: deps.supportsTransactionV1, + }), + ); + + if (!approved) { + return; + } + } + + yield* submitLeg(deps, signal, 'swap', { awaitSettlement }, () => + deps.dexContractRepository.buildSwapTransaction({ + network: deps.network, + publicKey: deps.publicKey, + firstToken, + secondToken, + path, + quoteType, + slippage, + deadline, + useTransactionV1: deps.supportsTransactionV1, + }), + ); +}; + +const toResult = (events: SwapFlowEvent[]): ISwapFlowResult => { + let approvalHash: string | undefined; + let swapHash: string | undefined; + let outcome: ITransactionSuccessOutcome | undefined; + let failure: { error: unknown } | undefined; + let cancelled = false; + + for (const event of events) { + switch (event.type) { + case 'approval:sent': + approvalHash = event.hash; + break; + case 'swap:sent': + swapHash = event.hash; + break; + case 'swap:confirmed': + outcome = event.outcome; + break; + case 'failed': + failure = { error: event.error }; + break; + case 'cancelled': + cancelled = true; + break; + default: + break; + } + } + + if (failure) { + return { status: 'failed', approvalHash, swapHash, error: failure.error }; + } + + if (cancelled) { + return { status: 'cancelled', approvalHash, swapHash }; + } + + return { status: 'success', approvalHash, swapHash, outcome }; +}; + +/** Builds the approve-then-swap flow as a hot, replayed handle per {@link IStartSwapFlowParams}. */ +export const createSwapFlowRunner = (deps: ISwapFlowDeps): ISwapFlowRunner => { + const active = new Map(); + + return { + publicKey: deps.publicKey, + + start(params: IStartSwapFlowParams): ISwapFlowHandle { + const id = uuid(); + + const handle = createFlowHandle({ + id, + generator: signal => runSwap(deps, params, signal), + toFailureEvent: (error): SwapFlowEvent => ({ type: 'failed', leg: 'swap', error }), + sideEvents$: deps.ledgerEvents$?.pipe( + map((event): SwapFlowEvent => ({ type: 'ledger', event })), + ), + toResult, + }); + + active.set(id, handle); + handle.done.finally(() => active.delete(id)).catch(() => undefined); + + return handle; + }, + getActive: (id: string): ISwapFlowHandle | null => active.get(id) ?? null, + }; +}; diff --git a/src/data/flows/wrapFlow.test.ts b/src/data/flows/wrapFlow.test.ts new file mode 100644 index 0000000..49632a9 --- /dev/null +++ b/src/data/flows/wrapFlow.test.ts @@ -0,0 +1,217 @@ +import { Subject, firstValueFrom, toArray } from 'rxjs'; + +import { createWrapFlowRunner } from './wrapFlow'; + +import type { ITransactionOutcome } from '../../domain/transactionStatus'; + +import { stubDexContractRepository, TEST_PUBLIC_KEY } from '../../__test-utils__/render-hook'; +import { TransactionTimeoutError } from '../../domain/transactionStatus'; +import type { ILedgerEvent } from '../../domain/ledger'; +import type { IStartWrapFlowParams } from '../../domain/flows'; +import type { IWrapFlowDeps } from './wrapFlow'; + +const BUILT_WRAP = { kind: 'wrap', transaction: {} } as never; +const BUILT_UNWRAP = { kind: 'unwrap', transaction: {} } as never; +/** The legacy-Deploy artifact: `isDeploy` is derived from the presence of this field. */ +const BUILT_WRAP_DEPLOY = { kind: 'wrap', deploy: {} } as never; + +const outcome = ( + hash: string, + status: 'success' | 'failure', + errorMessage?: string, +): ITransactionOutcome => + status === 'failure' + ? { hash, status, blockHeight: 1, errorMessage } + : { hash, status, blockHeight: 1 }; + +const makeDeps = (over: Partial = {}): IWrapFlowDeps => ({ + network: 'testnet', + publicKey: TEST_PUBLIC_KEY, + signer: { publicKeyHex: TEST_PUBLIC_KEY } as never, + supportsTransactionV1: true, + dexContractRepository: stubDexContractRepository({ + buildWrapTransaction: jest.fn().mockResolvedValue(BUILT_WRAP), + buildUnwrapTransaction: jest.fn().mockResolvedValue(BUILT_UNWRAP), + }), + casperTransactionsRepository: { + sendDexTransaction: jest.fn().mockResolvedValue('0xwrap'), + }, + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => outcome(hash, 'success')), + }, + ...over, +}); + +const startParams = (over: Partial = {}): IStartWrapFlowParams => ({ + direction: 'wrap', + rawAmount: '1000000000', + ...over, +}); + +const collect = async (deps: IWrapFlowDeps, params = startParams()) => { + const handle = createWrapFlowRunner(deps).start(params); + const events = await firstValueFrom(handle.events$.pipe(toArray())); + + return { handle, events, types: events.map(e => e.type), result: await handle.done }; +}; + +describe('createWrapFlowRunner', () => { + it('builds a wrap from the motes amount', async () => { + const deps = makeDeps(); + await collect(deps, { direction: 'wrap', rawAmount: '1000000000' }); + + expect(deps.dexContractRepository.buildWrapTransaction).toHaveBeenCalledWith( + expect.objectContaining({ motesAmount: '1000000000', useTransactionV1: true }), + ); + expect(deps.dexContractRepository.buildUnwrapTransaction).not.toHaveBeenCalled(); + }); + + it('builds an unwrap from the raw amount', async () => { + const deps = makeDeps(); + await collect(deps, { direction: 'unwrap', rawAmount: '1000000000' }); + + expect(deps.dexContractRepository.buildUnwrapTransaction).toHaveBeenCalledWith( + expect.objectContaining({ rawAmount: '1000000000', useTransactionV1: true }), + ); + expect(deps.dexContractRepository.buildWrapTransaction).not.toHaveBeenCalled(); + }); + + it('emits signing, sent and confirmed for a settled wrap', async () => { + const { types } = await collect(makeDeps(), { direction: 'wrap', rawAmount: '1' }); + + expect(types).toEqual(['wrap:signing', 'wrap:sent', 'wrap:confirmed']); + }); + + it('emits failed and resolves done as failed when submission fails', async () => { + const deps = makeDeps({ + casperTransactionsRepository: { + sendDexTransaction: jest.fn().mockRejectedValue(new Error('signing refused')), + }, + }); + + const { types, result } = await collect(deps, { direction: 'wrap', rawAmount: '1' }); + + expect(types).toEqual(['wrap:signing', 'failed']); + expect(result.status).toBe('failed'); + }); + + it('reports a revert as a failure and never as a confirmation', async () => { + const deps = makeDeps({ + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn(async ({ hash }: { hash: string }) => ({ + hash, + status: 'failure' as const, + blockHeight: 1, + errorMessage: 'User error: 3', + })), + }, + }); + + const { events, types, result } = await collect(deps, { direction: 'wrap', rawAmount: '1' }); + const failed = events.find(e => e.type === 'failed') as { error: unknown }; + + expect(types).not.toContain('wrap:confirmed'); + expect(types).toContain('failed'); + expect(result.status).toBe('failed'); + expect((failed.error as Error).message).toContain('User error: 3'); + }); + + it('tells the settlement watch which artifact was submitted', async () => { + const waitForTransaction = jest.fn(async ({ hash }: { hash: string }) => + outcome(hash, 'success'), + ); + const transactionStatusRepository = { observeTransaction: jest.fn(), waitForTransaction }; + + await collect(makeDeps({ transactionStatusRepository })); + + expect(waitForTransaction).toHaveBeenCalledWith(expect.objectContaining({ isDeploy: false })); + + waitForTransaction.mockClear(); + + await collect( + makeDeps({ + transactionStatusRepository, + dexContractRepository: stubDexContractRepository({ + buildWrapTransaction: jest.fn().mockResolvedValue(BUILT_WRAP_DEPLOY), + buildUnwrapTransaction: jest.fn().mockResolvedValue(BUILT_UNWRAP), + }), + }), + ); + + expect(waitForTransaction).toHaveBeenCalledWith(expect.objectContaining({ isDeploy: true })); + }); + + it('reports a settlement timeout as a failure, never as a confirmation', async () => { + const deps = makeDeps({ + transactionStatusRepository: { + observeTransaction: jest.fn(), + waitForTransaction: jest.fn().mockRejectedValue(new TransactionTimeoutError('0xwrap')), + }, + }); + + const { types, result } = await collect(deps, { direction: 'wrap', rawAmount: '1' }); + + expect(types).not.toContain('wrap:confirmed'); + expect(types).toContain('failed'); + expect(result.status).toBe('failed'); + }); + + it('cancels before submitting anything when cancel lands before the build resolves', async () => { + let releaseBuild: () => void = () => undefined; + const sendDexTransaction = jest.fn().mockResolvedValue('0xwrap'); + const deps = makeDeps({ + dexContractRepository: stubDexContractRepository({ + buildWrapTransaction: jest.fn( + () => new Promise(resolve => (releaseBuild = () => resolve(BUILT_WRAP))), + ), + buildUnwrapTransaction: jest.fn().mockResolvedValue(BUILT_UNWRAP), + }), + casperTransactionsRepository: { sendDexTransaction }, + }); + + const handle = createWrapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + await new Promise(resolve => setTimeout(resolve, 10)); + handle.cancel(); + releaseBuild(); + + expect((await events).map(e => e.type)).toContain('cancelled'); + expect(sendDexTransaction).not.toHaveBeenCalled(); + expect((await handle.done).status).toBe('cancelled'); + }); + + it('keeps running after every subscriber has unsubscribed', async () => { + const deps = makeDeps(); + const handle = createWrapFlowRunner(deps).start({ direction: 'wrap', rawAmount: '1' }); + + handle.events$.subscribe().unsubscribe(); + + await expect(handle.done).resolves.toMatchObject({ status: 'success' }); + }); + + it('completes at submission when settlement is not awaited', async () => { + const { types, result } = await collect(makeDeps(), { + direction: 'wrap', + rawAmount: '1', + awaitSettlement: false, + }); + + expect(types).toEqual(['wrap:signing', 'wrap:sent']); + expect(result).toMatchObject({ status: 'success', wrapHash: '0xwrap' }); + expect(result.outcome).toBeUndefined(); + }); + + it('merges ledger device events into the flow stream', async () => { + const ledgerEvents$ = new Subject(); + const deps = makeDeps({ ledgerEvents$ }); + const handle = createWrapFlowRunner(deps).start(startParams()); + const events = firstValueFrom(handle.events$.pipe(toArray())); + + ledgerEvents$.next({ status: 'waiting-response' } as unknown as ILedgerEvent); + + expect((await events).some(e => e.type === 'ledger')).toBe(true); + }); +}); diff --git a/src/data/flows/wrapFlow.ts b/src/data/flows/wrapFlow.ts new file mode 100644 index 0000000..52d28c0 --- /dev/null +++ b/src/data/flows/wrapFlow.ts @@ -0,0 +1,186 @@ +import { map } from 'rxjs'; +import { v4 as uuid } from 'uuid'; + +import { createFlowHandle } from './runner'; + +import { isLedgerSignatureCancelled } from '../../domain/ledger'; + +import type { + IBuiltDexTransaction, + ITransactionSuccessOutcome, + IStartWrapFlowParams, + IWrapFlowHandle, + IWrapFlowResult, + IWrapFlowRunner, + WrapFlowEvent, +} from '../../domain'; +import type { ISwapFlowDeps } from './swapFlow'; + +/** The same dependency set as the swap flow; wrap simply never uses the approval builders. */ +export type IWrapFlowDeps = ISwapFlowDeps; + +/** + * The single-leg wrap/unwrap sequence: build, sign, submit, then optionally wait for settlement. + * Unlike the swap flow there is no approval leg — wrapping spends native CSPR and unwrapping + * burns the caller's own WCSPR. + */ +const runWrap = async function* ( + deps: IWrapFlowDeps, + { direction, rawAmount, awaitSettlement = true }: IStartWrapFlowParams, + signal: AbortSignal, +): AsyncGenerator { + if (signal.aborted) { + yield { type: 'cancelled' }; + + return; + } + + yield { type: 'wrap:signing' }; + + let built: IBuiltDexTransaction; + let hash: string; + + try { + built = + direction === 'wrap' + ? await deps.dexContractRepository.buildWrapTransaction({ + network: deps.network, + publicKey: deps.publicKey, + motesAmount: rawAmount, + useTransactionV1: deps.supportsTransactionV1, + }) + : await deps.dexContractRepository.buildUnwrapTransaction({ + network: deps.network, + publicKey: deps.publicKey, + rawAmount, + useTransactionV1: deps.supportsTransactionV1, + }); + + if (signal.aborted) { + yield { type: 'cancelled' }; + + return; + } + + hash = await deps.casperTransactionsRepository.sendDexTransaction({ + built, + network: deps.network, + signer: deps.signer, + }); + } catch (error) { + yield (deps.isCancellationError ?? isLedgerSignatureCancelled)(error) + ? { type: 'cancelled' } + : { type: 'failed', error }; + + return; + } + + if (signal.aborted) { + yield { type: 'cancelled' }; + + return; + } + + yield { type: 'wrap:sent', hash }; + + if (!awaitSettlement) { + return; + } + + let outcome: Awaited>; + + try { + outcome = await deps.transactionStatusRepository.waitForTransaction({ + hash, + network: deps.network, + isDeploy: built.deploy !== undefined, + signal, + }); + } catch (error) { + yield signal.aborted ? { type: 'cancelled' } : { type: 'failed', error }; + + return; + } + + if (signal.aborted) { + yield { type: 'cancelled' }; + + return; + } + + if (outcome.status === 'failure') { + yield { + type: 'failed', + error: new Error(`Transaction failed: ${outcome.errorMessage ?? 'unknown reason'}`), + }; + + return; + } + + yield { type: 'wrap:confirmed', outcome }; +}; + +const toResult = (events: WrapFlowEvent[]): IWrapFlowResult => { + let wrapHash: string | undefined; + let outcome: ITransactionSuccessOutcome | undefined; + let failure: { error: unknown } | undefined; + let cancelled = false; + + for (const event of events) { + switch (event.type) { + case 'wrap:sent': + wrapHash = event.hash; + break; + case 'wrap:confirmed': + outcome = event.outcome; + break; + case 'failed': + failure = { error: event.error }; + break; + case 'cancelled': + cancelled = true; + break; + default: + break; + } + } + + if (failure) { + return { status: 'failed', wrapHash, error: failure.error }; + } + + if (cancelled) { + return { status: 'cancelled', wrapHash }; + } + + return { status: 'success', wrapHash, outcome }; +}; + +/** Builds the single-leg wrap/unwrap flow as a hot, replayed handle per {@link IStartWrapFlowParams}. */ +export const createWrapFlowRunner = (deps: IWrapFlowDeps): IWrapFlowRunner => { + const active = new Map(); + + return { + publicKey: deps.publicKey, + + start(params: IStartWrapFlowParams): IWrapFlowHandle { + const id = uuid(); + + const handle = createFlowHandle({ + id, + generator: signal => runWrap(deps, params, signal), + toFailureEvent: (error): WrapFlowEvent => ({ type: 'failed', error }), + sideEvents$: deps.ledgerEvents$?.pipe( + map((event): WrapFlowEvent => ({ type: 'ledger', event })), + ), + toResult, + }); + + active.set(id, handle); + handle.done.finally(() => active.delete(id)).catch(() => undefined); + + return handle; + }, + getActive: (id: string): IWrapFlowHandle | null => active.get(id) ?? null, + }; +}; diff --git a/src/data/ledger/index.ts b/src/data/ledger/index.ts new file mode 100644 index 0000000..f78beab --- /dev/null +++ b/src/data/ledger/index.ts @@ -0,0 +1 @@ +export * from './service'; diff --git a/src/data/ledger/service.test.ts b/src/data/ledger/service.test.ts new file mode 100644 index 0000000..78793a8 --- /dev/null +++ b/src/data/ledger/service.test.ts @@ -0,0 +1,696 @@ +import { blake2b } from '@noble/hashes/blake2'; +import { KeyAlgorithm, PrivateKey, Transaction } from 'casper-js-sdk'; +import { BehaviorSubject } from 'rxjs'; + +import { CasperLedgerService } from './service'; +import { ICasperLedgerServiceOptions, LedgerError, LedgerEventStatus } from '../../domain'; + +jest.mock('../../utils/common', () => ({ + delay: jest.fn().mockResolvedValue(undefined), +})); + +const CONNECTION_POLL_INTERVAL = 3000; + +const REAL_KEY = PrivateKey.generate(KeyAlgorithm.SECP256K1); +const PUBLIC_KEY_HEX = REAL_KEY.publicKey.toHex(); +const RAW_PUBLIC_KEY_BYTES = Buffer.from(PUBLIC_KEY_HEX.slice(2), 'hex'); +const ACCOUNT = { publicKey: PUBLIC_KEY_HEX, index: 0 }; + +const okSign = ( + len = 64, + overrides: Partial<{ returnCode: number; errorMessage: string }> = {}, +) => ({ + returnCode: 0x9000, + errorMessage: '', + signatureRSV: Buffer.alloc(len, 7), + ...overrides, +}); + +const okAppInfo = { returnCode: 0x9000, appName: 'Casper', appVersion: '3.0.5' }; + +const makeFakeApp = (over: Partial> = {}) => ({ + getAppInfo: jest.fn(async () => okAppInfo), + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 0x9000, + publicKey: RAW_PUBLIC_KEY_BYTES, + })), + sign: jest.fn(async () => okSign()), + signWasmDeploy: jest.fn(async () => okSign()), + signMessage: jest.fn(async () => okSign()), + ...over, +}); + +const makeTransport = () => ({ + on: jest.fn(), + off: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + setExchangeTimeout: jest.fn(), +}); + +const connectService = async ( + app: ReturnType, + options: Partial = {}, + isBluetoothTransport = false, +) => { + const service = new CasperLedgerService({ createLedgerApp: () => app as never, ...options }); + const transport = makeTransport(); + await service.connect( + async () => transport, + async () => true, + isBluetoothTransport, + ); + return { service, transport }; +}; + +/** Collects every raw BehaviorSubject.next() payload, bypassing the debounceTime(300) pipe. */ +const spyOnEvents = () => { + const events: Array<{ status: LedgerEventStatus; [key: string]: unknown }> = []; + const spy = jest.spyOn(BehaviorSubject.prototype, 'next').mockImplementation(function ( + this: BehaviorSubject, + value: unknown, + ) { + events.push(value as { status: LedgerEventStatus }); + return Object.getPrototypeOf(BehaviorSubject.prototype).next.call(this, value); + }); + return { events, restore: () => spy.mockRestore() }; +}; + +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +const makeTx = (over: { hash?: string; deploy?: unknown; bytes?: number[] } = {}) => + ({ + hash: { toHex: () => over.hash ?? 'deadbeef' }, + getDeploy: () => over.deploy, + toBytes: () => new Uint8Array(over.bytes ?? [0x01, 0x02, 0x03]), + setSignature: jest.fn(), + }) as unknown as Transaction & { setSignature: jest.Mock }; + +const makeDeploy = (over: { isModuleBytes?: boolean; bytes?: number[] } = {}) => ({ + session: { isModuleBytes: () => over.isModuleBytes ?? false }, + toBytes: () => new Uint8Array(over.bytes ?? [0x0a, 0x0b]), +}); + +describe('CasperLedgerService', () => { + describe('initial state', () => { + it('is not connected and exposes an empty account cache', () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + expect(service.isConnected).toBe(false); + expect(service.cachedAccounts).toEqual([]); + }); + }); + + describe('version gate', () => { + it('signs via the TransactionV1 path and reports supportsTransactionV1Cb(true) for a new app', async () => { + const app = makeFakeApp(); + const { service } = await connectService(app); + const tx = makeTx(); + const cb = jest.fn(); + + await service.signTransaction(tx, ACCOUNT, cb); + + expect(app.sign).toHaveBeenCalledWith(expect.any(String), Buffer.from(tx.toBytes())); + expect(cb).toHaveBeenCalledWith(ACCOUNT.publicKey, true); + }); + + it('signs the Deploy bytes and reports supportsTransactionV1Cb(false) for an old app', async () => { + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ ...okAppInfo, appVersion: '2.4.0' })), + }); + const { service } = await connectService(app); + const deploy = makeDeploy(); + const tx = makeTx({ deploy }); + const cb = jest.fn(); + + await service.signTransaction(tx, ACCOUNT, cb); + + expect(app.sign).toHaveBeenCalledWith(expect.any(String), Buffer.from(deploy.toBytes())); + expect(cb).toHaveBeenCalledWith(ACCOUNT.publicKey, false); + }); + + it('treats a missing appVersion as "2" (old-app path)', async () => { + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ ...okAppInfo, appVersion: undefined })), + }); + const { service } = await connectService(app); + const deploy = makeDeploy(); + const tx = makeTx({ deploy }); + const cb = jest.fn(); + + await service.signTransaction(tx, ACCOUNT, cb); + + expect(app.sign).toHaveBeenCalledWith(expect.any(String), Buffer.from(deploy.toBytes())); + expect(cb).toHaveBeenCalledWith(ACCOUNT.publicKey, false); + }); + + it('emits and throws TransactionForOldAppVersion for a 1.x-incapable tx on an old app', async () => { + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ ...okAppInfo, appVersion: '2.4.0' })), + }); + const { service } = await connectService(app); + const tx = makeTx(); // getDeploy() -> undefined + + await expect(service.signTransaction(tx, ACCOUNT)).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.TransactionForOldAppVersion), + }); + }); + + it('uses signWasmDeploy for a legacy WASM deploy even on v3+ apps', async () => { + const app = makeFakeApp(); + const { service } = await connectService(app); + const deploy = makeDeploy({ isModuleBytes: true, bytes: [0xde, 0xad] }); + const tx = makeTx({ deploy, bytes: [0xca, 0xfe] }); + + await service.signTransaction(tx, ACCOUNT); + + expect(app.signWasmDeploy).toHaveBeenCalledWith( + expect.any(String), + Buffer.from(tx.toBytes()), + ); + expect(app.sign).not.toHaveBeenCalled(); + }); + }); + + describe('getSignedTransaction', () => { + it('attaches the signature to the original tx on a new app and resolves it', async () => { + const app = makeFakeApp(); + const { service } = await connectService(app); + const tx = makeTx(); + + const signed = await service.getSignedTransaction(tx, ACCOUNT); + + expect(signed).toBe(tx); + expect(tx.setSignature).toHaveBeenCalledWith(expect.any(Uint8Array), expect.anything()); + }); + + it('signs and returns the fallback deploy tx on an old app', async () => { + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ ...okAppInfo, appVersion: '2.4.0' })), + }); + const { service } = await connectService(app); + const deploy = makeDeploy(); + const tx = makeTx(); // v1-only tx: getDeploy() -> undefined + const fallbackTx = makeTx({ deploy }); + + const signed = await service.getSignedTransaction(tx, ACCOUNT, fallbackTx); + + expect(signed).toBe(fallbackTx); + expect(fallbackTx.setSignature).toHaveBeenCalled(); + expect(tx.setSignature).not.toHaveBeenCalled(); + }); + + it('throws TransactionForOldAppVersion when no fallback is supplied for an old app', async () => { + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ ...okAppInfo, appVersion: '2.4.0' })), + }); + const { service } = await connectService(app); + const tx = makeTx(); + + await expect(service.getSignedTransaction(tx, ACCOUNT)).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.TransactionForOldAppVersion), + }); + }); + }); + + describe('identity pre-flight (D4)', () => { + it('fails with SignatureFailed when the on-device key does not match the account key', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 0x9000, + publicKey: Buffer.alloc(33, 0xff), + })), + }); + const { service } = await connectService(app); + const tx = makeTx(); + + await expect(service.signTransaction(tx, ACCOUNT)).rejects.toMatchObject({ + message: JSON.stringify({ + status: LedgerEventStatus.SignatureFailed, + error: 'Signing key not found on Ledger device. Signature process failed', + publicKey: ACCOUNT.publicKey, + txHash: 'deadbeef', + }), + }); + expect(app.sign).not.toHaveBeenCalled(); + }); + }); + + describe('user rejection', () => { + it('rejects with SignatureCanceled on a 0x6986 return code (tx)', async () => { + const app = makeFakeApp({ sign: jest.fn(async () => okSign(64, { returnCode: 0x6986 })) }); + const { service } = await connectService(app); + const tx = makeTx(); + + await expect(service.signTransaction(tx, ACCOUNT)).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.SignatureCanceled), + }); + }); + + it('rejects with MsgSignatureCanceled on a 0x6986 return code (message)', async () => { + const app = makeFakeApp({ + signMessage: jest.fn(async () => okSign(64, { returnCode: 0x6986 })), + }); + const { service } = await connectService(app); + + await expect(service.signMessage('hi', ACCOUNT)).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.MsgSignatureCanceled), + }); + }); + }); + + describe('other non-0x9000 return codes', () => { + it('rejects with SignatureFailed and propagates the device errorMessage', async () => { + const app = makeFakeApp({ + sign: jest.fn(async () => okSign(64, { returnCode: 0x6a80, errorMessage: 'boom' })), + }); + const { service } = await connectService(app); + const tx = makeTx(); + + await expect(service.signTransaction(tx, ACCOUNT)).rejects.toMatchObject({ + message: JSON.stringify({ + status: LedgerEventStatus.SignatureFailed, + error: 'boom', + publicKey: ACCOUNT.publicKey, + txHash: 'deadbeef', + }), + }); + }); + }); + + describe('signature post-processing', () => { + it('strips the V byte from a 65-byte RSV signature and prefixes 0x02', async () => { + const app = makeFakeApp({ sign: jest.fn(async () => okSign(65)) }); + const { service } = await connectService(app); + const tx = makeTx(); + + const resp = await service.signTransaction(tx, ACCOUNT); + + expect(resp.signature).toHaveLength(64); + expect(resp.prefixedSignature[0]).toBe(0x02); + expect(resp.prefixedSignature).toHaveLength(65); + expect(resp.prefixedSignatureHex).toBe(`02${resp.signatureHex}`); + expect(resp.prefixedSignatureHex.startsWith('02')).toBe(true); + }); + }); + + describe('message signing', () => { + it('hashes the prefixed message for display, signs raw prefixed bytes, and returns both signature variants', async () => { + const app = makeFakeApp(); + const { service, transport } = await connectService(app); + const { events, restore } = spyOnEvents(); + + const result = await service.signMessage('msg', ACCOUNT); + restore(); + + const expectedPrefixed = Buffer.from('Casper Message:\nmsg', 'utf-8'); + const expectedHash = Buffer.from(blake2b(expectedPrefixed, { dkLen: 32 })).toString('hex'); + + expect(app.signMessage).toHaveBeenCalledWith(expect.any(String), expectedPrefixed); + expect(transport.setExchangeTimeout).toHaveBeenCalledWith(10000); + + const requested = events.find( + e => e.status === LedgerEventStatus.MsgSignatureRequestedToUser, + ); + expect(requested?.msgHash).toBe(expectedHash); + expect(result.signature).toHaveLength(64); + expect(result.signatureHex).toBe('07'.repeat(64)); + }); + }); + + describe('restore callback', () => { + it('emits WaitingResponseFromDevice, awaits tryRestoreConnection, then re-checks the connection', async () => { + const app = makeFakeApp(); + const service = new CasperLedgerService({ createLedgerApp: () => app as never }); + const tryRestoreConnection = jest.fn(async () => undefined); + const { events, restore } = spyOnEvents(); + + await expect( + service.signTransaction(makeTx(), ACCOUNT, undefined, tryRestoreConnection), + ).rejects.toBeInstanceOf(LedgerError); + + restore(); + expect(tryRestoreConnection).toHaveBeenCalled(); + expect(events.some(e => e.status === LedgerEventStatus.WaitingResponseFromDevice)).toBe(true); + }); + + it('does not forward tryRestoreConnection to the inner signTransaction call from getSignedTransaction', async () => { + const app = makeFakeApp(); + const { service } = await connectService(app); + const tx = makeTx(); + const tryRestoreConnection = jest.fn(async () => undefined); + + // Already connected, so the outer preamble is a no-op; asserts the flow still succeeds + // and the callback is never invoked for the (already-connected) inner call. + await service.getSignedTransaction(tx, ACCOUNT, undefined, undefined, tryRestoreConnection); + + expect(tryRestoreConnection).not.toHaveBeenCalled(); + }); + }); + + describe('connection check', () => { + it('rejects with InvalidIndex for a NaN account index', async () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + + await expect( + service.signTransaction(makeTx(), { publicKey: ACCOUNT.publicKey, index: NaN }), + ).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.InvalidIndex), + }); + }); + + it('does not attempt a transport when availability check fails', async () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + const transportCreator = jest.fn(async () => makeTransport()); + + await expect( + service.connect(transportCreator as never, async () => false), + ).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.NotAvailable), + }); + + expect(transportCreator).not.toHaveBeenCalled(); + expect(service.isConnected).toBe(false); + }); + + it('rejects with Disconnected when not connected', async () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + + await expect(service.signTransaction(makeTx(), ACCOUNT)).rejects.toMatchObject({ + message: expect.stringContaining(LedgerEventStatus.Disconnected), + }); + }); + + it('emits CasperAppNotLoaded when a non-Casper app is active on the device', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'nextTick'] }); + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ + returnCode: 0x9000, + appName: 'Ethereum', + appVersion: '1.0.0', + })), + }); + const service = new CasperLedgerService({ createLedgerApp: () => app as never }); + const { events, restore } = spyOnEvents(); + + // connect() never settles on this path (never reaches Connected nor a rejecting status). + service + .connect( + async () => makeTransport(), + async () => true, + ) + .catch(() => undefined); + await flushMicrotasks(); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.CasperAppNotLoaded)).toBe(true); + expect(service.isConnected).toBe(false); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); + }); + + describe('locked device', () => { + it('emits DeviceLocked during connect when appInfo returnCode is 0xffff', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'nextTick'] }); + const app = makeFakeApp({ + getAppInfo: jest.fn(async () => ({ + returnCode: 0xffff, + appName: 'Casper', + appVersion: '3.0.0', + })), + }); + const service = new CasperLedgerService({ createLedgerApp: () => app as never }); + const { events, restore } = spyOnEvents(); + + // connect() never settles on this path (never reaches Connected nor a rejecting status). + service + .connect( + async () => makeTransport(), + async () => true, + ) + .catch(() => undefined); + await flushMicrotasks(); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.DeviceLocked)).toBe(true); + expect(service.isConnected).toBe(false); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('emits DeviceLocked during getAccountList when returnCode is 21781', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 21781, + publicKey: Buffer.alloc(33), + })), + }); + const { service } = await connectService(app); + const { events, restore } = spyOnEvents(); + + await expect(service.getAccountList({ size: 1, offset: 0 })).rejects.toBeInstanceOf( + LedgerError, + ); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.DeviceLocked)).toBe(true); + expect(events.some(e => e.status === LedgerEventStatus.AccountListUpdated)).toBe(false); + expect(service.cachedAccounts).toHaveLength(0); + }); + + it('stops getAccountList when the Casper app is not loaded', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 0x6e01, + publicKey: Buffer.alloc(33), + })), + }); + const { service } = await connectService(app); + const { events, restore } = spyOnEvents(); + + await expect(service.getAccountList({ size: 1, offset: 0 })).rejects.toBeInstanceOf( + LedgerError, + ); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.CasperAppNotLoaded)).toBe(true); + expect(events.some(e => e.status === LedgerEventStatus.AccountListUpdated)).toBe(false); + expect(service.cachedAccounts).toHaveLength(0); + }); + + it('checkAppInfo returns WaitingToSignPrevDeploy for returnCode 65535 (0xffff)', async () => { + const app = makeFakeApp(); + const { service } = await connectService(app); + app.getAppInfo.mockResolvedValueOnce({ + returnCode: 65535, + appName: 'Casper', + appVersion: '3.0.0', + }); + + await expect(service.checkAppInfo()).resolves.toBe(LedgerEventStatus.WaitingToSignPrevDeploy); + }); + }); + + describe('pairing invalidated', () => { + it('emits BluetoothPairingInvalidated and rejects connect when the injected classifier matches', async () => { + const app = makeFakeApp(); + const service = new CasperLedgerService({ + createLedgerApp: () => app as never, + isPairingInvalidatedError: () => true, + }); + const { events, restore } = spyOnEvents(); + + await expect( + service.connect( + async () => { + throw new Error('pairing dropped'); + }, + async () => true, + true, + ), + ).rejects.toBeInstanceOf(LedgerError); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.BluetoothPairingInvalidated)).toBe( + true, + ); + }); + + it('falls back to ErrorOpeningDevice when the classifier does not match', async () => { + const app = makeFakeApp(); + const service = new CasperLedgerService({ + createLedgerApp: () => app as never, + isPairingInvalidatedError: () => false, + }); + const { events, restore } = spyOnEvents(); + + await expect( + service.connect( + async () => { + throw new Error('generic failure'); + }, + async () => true, + true, + ), + ).rejects.toBeInstanceOf(LedgerError); + restore(); + + expect(events.some(e => e.status === LedgerEventStatus.ErrorOpeningDevice)).toBe(true); + expect(events.some(e => e.status === LedgerEventStatus.BluetoothPairingInvalidated)).toBe( + false, + ); + }); + }); + + describe('BLE settle delay', () => { + it('schedules a 200ms settle delay after a round-trip only for bluetooth transport', async () => { + const app = makeFakeApp(); + const { service: bleService } = await connectService(app, {}, true); + + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + await bleService.checkAppInfo(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 200); + setTimeoutSpy.mockRestore(); + }); + + it('does not add a settle delay for USB transport', async () => { + const app = makeFakeApp(); + const { service: usbService } = await connectService(app, {}, false); + + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + await usbService.checkAppInfo(); + expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 200); + setTimeoutSpy.mockRestore(); + }); + }); + + describe('event stream', () => { + it('starts with Disconnected and delivers status updates debounced by 300ms', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'nextTick'] }); + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + const received: LedgerEventStatus[] = []; + const sub = service.subscribeToLedgerEventStatus(evt => received.push(evt.status)); + + await jest.advanceTimersByTimeAsync(300); + expect(received).toEqual([LedgerEventStatus.Disconnected]); + + sub.unsubscribe(); + jest.useRealTimers(); + }); + + it('deduplicates identical statuses via distinct() before they reach the observer', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'nextTick'] }); + const app = makeFakeApp({ + getAppInfo: jest + .fn() + .mockResolvedValueOnce({ returnCode: 0x1234, appName: 'Casper', appVersion: '3.0.0' }) + .mockResolvedValueOnce(okAppInfo), + }); + const service = new CasperLedgerService({ createLedgerApp: () => app as never }); + const { events, restore } = spyOnEvents(); + + const connectPromise = service.connect( + async () => makeTransport(), + async () => true, + ); + await flushMicrotasks(); + await jest.advanceTimersByTimeAsync(CONNECTION_POLL_INTERVAL); + await connectPromise; + restore(); + + const waitingCount = events.filter( + e => e.status === LedgerEventStatus.WaitingResponseFromDevice, + ).length; + expect(waitingCount).toBe(1); + expect(service.isConnected).toBe(true); + + jest.useRealTimers(); + }); + }); + + describe('ledgerEvents$', () => { + it('replays the current event to a late subscriber', () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + const seen: LedgerEventStatus[] = []; + + service.ledgerEvents$.subscribe(evt => seen.push(evt.status)); + + expect(seen).toHaveLength(1); + }); + + it('emits without waiting out the callback path debounce', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 21781, + publicKey: Buffer.alloc(33), + })), + }); + const { service } = await connectService(app); + const seen: LedgerEventStatus[] = []; + + service.ledgerEvents$.subscribe(evt => seen.push(evt.status)); + const before = seen.length; + + await expect(service.getAccountList({ size: 1, offset: 0 })).rejects.toBeInstanceOf( + LedgerError, + ); + + expect(seen.length).toBeGreaterThan(before); + }); + + it('does not expose a publishing surface', () => { + const service = new CasperLedgerService({ createLedgerApp: () => makeFakeApp() as never }); + + expect((service.ledgerEvents$ as unknown as { next?: unknown }).next).toBeUndefined(); + }); + }); + + describe('getAccountList', () => { + it('fetches accounts sequentially, encodes them as hex, and caches the result', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest + .fn() + .mockResolvedValueOnce({ returnCode: 0x9000, publicKey: Buffer.from([0xaa]) }) + .mockResolvedValueOnce({ returnCode: 0x9000, publicKey: Buffer.from([0xbb]) }), + }); + const { service } = await connectService(app); + const { events, restore } = spyOnEvents(); + + await service.getAccountList({ size: 2, offset: 0 }); + restore(); + + expect(app.getAddressAndPubKey).toHaveBeenNthCalledWith(1, "m/44'/506'/0'/0/0"); + expect(app.getAddressAndPubKey).toHaveBeenNthCalledWith(2, "m/44'/506'/0'/0/1"); + + const updated = events.find(e => e.status === LedgerEventStatus.AccountListUpdated); + expect(updated?.accounts).toEqual([ + { publicKey: '02aa', index: 0 }, + { publicKey: '02bb', index: 1 }, + ]); + expect(updated?.appVersion).toBe('3.0.5'); + expect(service.cachedAccounts).toEqual([ + { publicKey: '02aa', index: 0 }, + { publicKey: '02bb', index: 1 }, + ]); + }); + + it('emits AccountListFailed and throws for an unrecognized error return code', async () => { + const app = makeFakeApp({ + getAddressAndPubKey: jest.fn(async () => ({ + returnCode: 0x6e00, + publicKey: Buffer.alloc(0), + })), + }); + const { service } = await connectService(app); + + await expect(service.getAccountList({ size: 1, offset: 0 })).rejects.toBeInstanceOf( + LedgerError, + ); + }); + }); +}); diff --git a/src/data/ledger/service.ts b/src/data/ledger/service.ts new file mode 100644 index 0000000..d95e6c6 --- /dev/null +++ b/src/data/ledger/service.ts @@ -0,0 +1,697 @@ +import { blake2b } from '@noble/hashes/blake2'; +import { HexBytes, PublicKey, Transaction } from 'casper-js-sdk'; +import { BehaviorSubject, debounceTime, distinct, Observable, Observer, Subscription } from 'rxjs'; + +import { + ICasperLedgerService, + ICasperLedgerServiceOptions, + ILedgerCasperApp, + ILedgerEvent, + ILedgerSignResponse, + ILedgerTransport, + LedgerAccount, + LedgerAccountsOptions, + LedgerError, + LedgerEventStatus, + SignResult, + TransportAvailabilityCheck, + TransportCreator, +} from '../../domain'; +import { delay } from '../../utils/common'; + +const CONNECTION_TIMEOUT_MS = 60000; +const CONNECTION_POLL_INTERVAL = 3000; + +// Registered at https://github.com/satoshilabs/slips/blob/master/slip-0044.md +const CSPR_COIN_INDEX = 506; + +function getBip44Path(index: number): string { + return [ + 'm', + "44'", // bip 44 + `${CSPR_COIN_INDEX}'`, // coin index + "0'", // wallet + '0', // external + `${index}`, // child account index + ].join('/'); +} + +export class CasperLedgerService implements ICasperLedgerService { + cachedAccounts: LedgerAccount[] = []; + + #transport: ILedgerTransport | null = null; + #isBluetoothTransport: boolean = false; + #ledgerApp: ILedgerCasperApp | null = null; + #ledgerConnected = false; + #allowReconnect: boolean = true; + #options: ICasperLedgerServiceOptions; + #createLedgerApp: (transport: ILedgerTransport) => ILedgerCasperApp; + #ledgerEventStatusSubject = new BehaviorSubject({ + status: LedgerEventStatus.Disconnected, + }); + + constructor(options: ICasperLedgerServiceOptions) { + this.#options = options; + this.#createLedgerApp = options.createLedgerApp; + } + + subscribeToLedgerEventStatus = (onData: (evt: ILedgerEvent) => void): Subscription => + this.#ledgerEventStatusSubject.pipe(debounceTime(300)).subscribe(onData); + + readonly ledgerEvents$: Observable = this.#ledgerEventStatusSubject.asObservable(); + + /** @throws {LedgerError} */ + async connect( + transportCreator: TransportCreator, + checkTransportAvailability: TransportAvailabilityCheck, + isBluetoothTransport = false, + ): Promise { + this.#isBluetoothTransport = isBluetoothTransport; + + return new Promise(async (resolve, reject) => { + const available = await checkTransportAvailability(); + + if (!available) { + const evt = { status: LedgerEventStatus.NotAvailable }; + this.#ledgerEventStatusSubject.next(evt); + reject(new LedgerError(evt)); + + return; + } + + const connectionObserver: Observer = { + next: data => { + this.#ledgerEventStatusSubject.next(data); + + if ( + data.status === LedgerEventStatus.Timeout || + data.status === LedgerEventStatus.ErrorOpeningDevice || + data.status === LedgerEventStatus.BluetoothPairingInvalidated + ) { + reject(new LedgerError(data)); + } + }, + error: e => { + const evt: ILedgerEvent = { + status: this.#options.isPairingInvalidatedError?.(e) + ? LedgerEventStatus.BluetoothPairingInvalidated + : LedgerEventStatus.ErrorOpeningDevice, + }; + this.#ledgerEventStatusSubject.next(evt); + reject(new LedgerError(evt)); + }, + complete: async () => { + resolve(); + }, + }; + + const tryToConnect = async (withRetry = true): Promise => { + try { + this.#transport = await transportCreator(); + this.#transport?.on('disconnect', this.#onDisconnect); + this.#ledgerApp = this.#createLedgerApp(this.#transport); + } catch (e) { + if (withRetry) { + await delay(500); + await tryToConnect(false); + } else { + throw e; + } + } + }; + + try { + await tryToConnect(); + } catch (e) { + if (!this.#transport) { + const evt: ILedgerEvent = { + status: this.#options.isPairingInvalidatedError?.(e) + ? LedgerEventStatus.BluetoothPairingInvalidated + : LedgerEventStatus.ErrorOpeningDevice, + }; + this.#ledgerEventStatusSubject.next(evt); + reject(new LedgerError(evt)); + return; + } + } + + this.#connectToLedger(transportCreator, connectionObserver); + }); + } + + async disconnect(): Promise { + if (this.#ledgerConnected) { + try { + await this.#transport?.close(); + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.Disconnected, + }); + } catch { + // best-effort close; the device is being abandoned either way + } + + this.#ledgerConnected = false; + } + + this.cachedAccounts = []; + + return true; + } + + get isConnected(): boolean { + return this.#ledgerConnected; + } + + async checkAppInfo(): Promise { + if (this.#ledgerConnected && this.#ledgerApp) { + const appInfo = await this.#ledgerApp?.getAppInfo(); + + await this.#processDelayAfterAction(); + + if (appInfo.returnCode === 65535) { + return LedgerEventStatus.WaitingToSignPrevDeploy; + } + + return appInfo.returnCode === 0x9000 && appInfo.appName === 'Casper' + ? null + : LedgerEventStatus.WaitingResponseFromDevice; + } + + return LedgerEventStatus.WaitingResponseFromDevice; + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + getAccountList = async ({ size, offset }: LedgerAccountsOptions): Promise => { + try { + if (!this.#ledgerApp || !this.#ledgerConnected) { + return; + } + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.LoadingAccountsList, + }); + + const response = await this.#ledgerApp.getAddressAndPubKey(this.#getAccountPath(offset)); + await this.#processDelayAfterAction(); + + if (!response || response.returnCode !== 0x9000) { + if (response?.returnCode === 0xffff || response.returnCode === 21781) { + this.#processError({ status: LedgerEventStatus.DeviceLocked }); + } else if (response?.returnCode === 0x6e01) { + this.#processError({ status: LedgerEventStatus.CasperAppNotLoaded }); + } else { + this.#processError({ status: LedgerEventStatus.AccountListFailed }); + } + } + + const publicKeys: string[] = [this.#encodePublicKey(response.publicKey)]; + + for (let i = 1; i < size; i++) { + const key = await this.#ledgerApp.getAddressAndPubKey(this.#getAccountPath(offset + i)); + await this.#processDelayAfterAction(); + + publicKeys.push(this.#encodePublicKey(key.publicKey)); + } + + const updatedAccountList = publicKeys.map((pk, i) => ({ + publicKey: pk, + index: offset + i, + })); + + const appInfo = await this.#ledgerApp?.getAppInfo(); + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.AccountListUpdated, + firstAcctIndex: offset, + accounts: updatedAccountList, + appVersion: appInfo.appVersion, + }); + + if (offset === this.cachedAccounts.length) { + this.cachedAccounts.push(...updatedAccountList); + } + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ status: LedgerEventStatus.AccountListFailed }); + } + } + }; + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + async signTransaction( + tx: Transaction, + account: Partial, + supportsTransactionV1Cb?: (publicKey: string, supports: boolean) => Promise, + tryRestoreConnection?: () => Promise, + ): Promise { + try { + if (account.index === undefined) { + this.#processError({ status: LedgerEventStatus.InvalidIndex }); + } + + if (!this.#ledgerConnected && tryRestoreConnection) { + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.WaitingResponseFromDevice, + }); + await tryRestoreConnection(); + } + + await this.#checkConnection(account.index); + + const appInfo = await this.#ledgerApp?.getAppInfo(); + const appSupportsTransactionV1 = Number(appInfo?.appVersion?.[0] ?? 2) > 2; + + if (!appSupportsTransactionV1 && !tx.getDeploy()) { + this.#processError({ + status: LedgerEventStatus.TransactionForOldAppVersion, + }); + } + + const txHash = tx.hash.toHex(); + + const devicePk = + account.index !== undefined + ? await this.#ledgerApp?.getAddressAndPubKey(this.#getAccountPath(account.index)) + : undefined; + await this.#processDelayAfterAction(); + + if (!devicePk) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'Could not retrieve key by index from device', + publicKey: account.publicKey, + txHash, + }); + } + + const keyFromDevice: string = this.#encodePublicKey(devicePk.publicKey); + + if (account.publicKey !== keyFromDevice) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'Signing key not found on Ledger device. Signature process failed', + publicKey: account.publicKey, + txHash, + }); + } + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.SignatureRequestedToUser, + publicKey: account.publicKey, + txHash, + }); + + let result: ILedgerSignResponse | undefined; + + if (appSupportsTransactionV1) { + if (tx.getDeploy()?.session?.isModuleBytes()) { + // Ledger app v3 still requires signWasmDeploy for legacy WASM Deploys + result = await this.#ledgerApp?.signWasmDeploy( + this.#getAccountPath(account.index), + Buffer.from(tx.toBytes()), + ); + } else { + const txBytes = tx.toBytes(); + result = await this.#ledgerApp?.sign( + this.#getAccountPath(account.index), + Buffer.from(txBytes), + ); + } + + supportsTransactionV1Cb?.(account.publicKey, true); + } else { + const deploy = tx.getDeploy(); + + if (!deploy) { + this.#processError({ + status: LedgerEventStatus.TransactionForOldAppVersion, + }); + } + + if (deploy.session.isModuleBytes()) { + result = await this.#ledgerApp?.signWasmDeploy( + this.#getAccountPath(account.index), + Buffer.from(deploy.toBytes()), + ); + } else { + result = await this.#ledgerApp?.sign( + this.#getAccountPath(account.index), + Buffer.from(deploy.toBytes()), + ); + } + + supportsTransactionV1Cb?.(account.publicKey, false); + } + + await this.#processDelayAfterAction(); + + if (!result) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'No response from device', + publicKey: account.publicKey, + txHash, + }); + } + + if (result.returnCode === 0x6986) { + // transaction rejected + this.#processError({ + status: LedgerEventStatus.SignatureCanceled, + publicKey: account.publicKey, + txHash, + }); + } + + if (result.returnCode !== 0x9000) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: result.errorMessage, + publicKey: account.publicKey, + txHash, + }); + } + + // remove V byte if included + const patchedSignature = + result.signatureRSV.length > 64 ? result.signatureRSV.subarray(0, 64) : result.signatureRSV; + + const prefixedSignatureHex = `02${patchedSignature.toString('hex')}`; + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.SignatureCompleted, + publicKey: account.publicKey, + txHash, + signatureHex: prefixedSignatureHex, + }); + + const prefix = new Uint8Array([0x02]); + + if (!prefixedSignatureHex) { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + publicKey: account.publicKey, + txHash, + error: 'Empty signature', + }); + } + + return { + signatureHex: patchedSignature.toString('hex'), + signature: patchedSignature, + prefixedSignatureHex, + prefixedSignature: new Uint8Array([...prefix, ...patchedSignature]), + }; + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ + status: LedgerEventStatus.SignatureFailed, + error: 'Unknown signature error', + }); + } + } + } + + async getSignedTransaction( + tx: Transaction, + account: Partial> & Pick, + fallbackTxFromDeploy?: Transaction, + supportsTransactionV1Cb?: (publicKey: string, supports: boolean) => Promise, + tryRestoreConnection?: () => Promise, + ): Promise { + if (account.index === undefined) { + this.#processError({ status: LedgerEventStatus.InvalidIndex }); + } + + if (!this.#ledgerConnected && tryRestoreConnection) { + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.WaitingResponseFromDevice, + }); + await tryRestoreConnection(); + } + + await this.#checkConnection(account.index); + + const appInfo = await this.#ledgerApp?.getAppInfo(); + const appSupportsTransactionV1 = Number(appInfo?.appVersion?.[0] ?? 2) > 2; + + if (appSupportsTransactionV1) { + const resp = await this.signTransaction(tx, account, supportsTransactionV1Cb); + + tx.setSignature( + HexBytes.fromHex(resp.prefixedSignatureHex).bytes, + PublicKey.fromHex(account.publicKey), + ); + + return tx; + } else { + if (!fallbackTxFromDeploy) { + this.#processError({ + status: LedgerEventStatus.TransactionForOldAppVersion, + }); + } + + const resp = await this.signTransaction( + fallbackTxFromDeploy, + account, + supportsTransactionV1Cb, + ); + + fallbackTxFromDeploy.setSignature( + HexBytes.fromHex(resp.prefixedSignatureHex).bytes, + PublicKey.fromHex(account.publicKey), + ); + + return fallbackTxFromDeploy; + } + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + async signMessage( + message: string, + account: Partial, + tryRestoreConnection?: () => Promise, + ): Promise> { + try { + if (account.index === undefined) { + this.#processError({ status: LedgerEventStatus.InvalidIndex }); + } + + if (!this.#ledgerConnected && tryRestoreConnection) { + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.WaitingResponseFromDevice, + }); + await tryRestoreConnection(); + } + + await this.#checkConnection(account.index); + + const prefixedMessage = Buffer.from(`Casper Message:\n${message}`, 'utf-8'); + const hashedMessage = Buffer.from(blake2b(prefixedMessage, { dkLen: 32 })).toString('hex'); + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.MsgSignatureRequestedToUser, + publicKey: account.publicKey, + message, + msgHash: hashedMessage, + }); + + this.#transport?.setExchangeTimeout(10000); + + const result: ILedgerSignResponse | undefined = await this.#ledgerApp?.signMessage( + this.#getAccountPath(account.index), + prefixedMessage, + ); + + await this.#processDelayAfterAction(); + + if (!result) { + this.#processError({ + status: LedgerEventStatus.MsgSignatureFailed, + error: 'No response from device', + }); + } + + if (result.returnCode === 0x6986) { + // transaction rejected + this.#processError({ status: LedgerEventStatus.MsgSignatureCanceled }); + } + + if (result.returnCode !== 0x9000) { + this.#processError({ + status: LedgerEventStatus.MsgSignatureFailed, + error: `Error: ${result.errorMessage}`, + }); + } + + // remove V byte if included + const patchedSignature = + result.signatureRSV.length > 64 ? result.signatureRSV.subarray(0, 64) : result.signatureRSV; + + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.MsgSignatureCompleted, + publicKey: account.publicKey, + message: message, + msgHash: hashedMessage, + signatureHex: patchedSignature.toString('hex'), + }); + + return { + signatureHex: patchedSignature.toString('hex'), + signature: patchedSignature, + }; + } catch (e) { + if (e instanceof LedgerError) { + throw e; + } else { + this.#processError({ + status: LedgerEventStatus.MsgSignatureFailed, + error: 'Unknown msg signature error', + }); + } + } + } + + #checkConnection = async (accountIndex?: number) => { + let evt; + + if (Number.isNaN(Number(accountIndex))) { + evt = { status: LedgerEventStatus.InvalidIndex }; + } else if (!this.#ledgerConnected) { + evt = { status: LedgerEventStatus.Disconnected }; + } else { + const status = await this.checkAppInfo(); + + if (status) { + evt = { status }; + } + } + + if (evt) { + this.#processError(evt); + } + }; + + #onDisconnect = () => { + this.#ledgerConnected = false; + this.#allowReconnect = false; + this.cachedAccounts = []; + this.#ledgerEventStatusSubject.next({ + status: LedgerEventStatus.Disconnected, + }); + this.#transport?.off('disconnect', this.#onDisconnect); + this.#transport = null; + + setTimeout(() => { + this.#allowReconnect = true; + }, CONNECTION_POLL_INTERVAL * 1.2); + }; + + #getAccountPath = (acctIdx: number): string => getBip44Path(acctIdx); + + #encodePublicKey = (bytes: Uint8Array) => '02' + Buffer.from(bytes).toString('hex'); + + #connectToLedger(transportCreator: TransportCreator, observer: Observer): void { + const observable = new Observable(subscriber => { + /** @return {boolean} is should stop retries */ + const retryConnection = async (): Promise => { + if (!this.#transport) { + try { + this.#transport = await transportCreator(); + this.#transport.on('disconnect', this.#onDisconnect); + this.#ledgerApp = this.#createLedgerApp(this.#transport); + } catch (e) { + subscriber.next({ + status: this.#options.isPairingInvalidatedError?.(e) + ? LedgerEventStatus.BluetoothPairingInvalidated + : LedgerEventStatus.ErrorOpeningDevice, + }); + + return true; + } + } + + if (!this.#transport || !this.#ledgerApp) { + return false; + } + + subscriber.next({ + status: LedgerEventStatus.WaitingResponseFromDevice, + }); + + try { + const appInfo = await this.#ledgerApp.getAppInfo(); + await this.#processDelayAfterAction(); + + if (appInfo.returnCode === 0xffff || appInfo.returnCode === 21781) { + subscriber.next({ status: LedgerEventStatus.DeviceLocked }); + + return false; + } + + if (appInfo.returnCode !== 0x9000) { + return false; + } + + if (appInfo.appName !== 'Casper') { + subscriber.next({ status: LedgerEventStatus.CasperAppNotLoaded }); + + return false; + } + + this.#ledgerConnected = true; + subscriber.next({ status: LedgerEventStatus.Connected }); + + return true; + } catch { + // device round-trip failed; fall through to the next retry attempt + } + + return false; + }; + + retryConnection().then(async shouldStopRetries => { + if (shouldStopRetries) { + subscriber.complete(); + } else { + let timeoutLoops = CONNECTION_TIMEOUT_MS / CONNECTION_POLL_INTERVAL; + + const timer = setInterval(async () => { + if (--timeoutLoops <= 0) { + clearInterval(timer); + subscriber.next({ status: LedgerEventStatus.Timeout }); + } else if (!this.#allowReconnect) { + return; + } else if (await retryConnection()) { + clearInterval(timer); + subscriber.complete(); + } + }, CONNECTION_POLL_INTERVAL); + } + }); + }); + + observable.pipe(distinct(({ status }) => status)).subscribe(observer); + } + + /** @throws {LedgerError} message - ILedgerEvent JSON */ + #processError(evt: ILedgerEvent): never { + this.#ledgerEventStatusSubject.next(evt); + throw new LedgerError(evt); + } + + /** Bluetooth transports need a beat after a resolved promise before the next call. */ + async #processDelayAfterAction() { + if (this.#isBluetoothTransport) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + } +} + +export const createCasperLedgerService = ( + options: ICasperLedgerServiceOptions, +): ICasperLedgerService => new CasperLedgerService(options); diff --git a/src/data/ledger/vendor-contracts.test.ts b/src/data/ledger/vendor-contracts.test.ts new file mode 100644 index 0000000..587a0dd --- /dev/null +++ b/src/data/ledger/vendor-contracts.test.ts @@ -0,0 +1,18 @@ +import type Transport from '@ledgerhq/hw-transport'; +import type CasperApp from '@zondax/ledger-casper'; + +import type { ILedgerCasperApp, ILedgerTransport } from '../../domain'; + +/** + * `ILedgerTransport` and `ILedgerCasperApp` are declared by hand so the vendor packages stay + * optional peers, out of the library's import graph. Nothing else checks that the hand-written + * shapes still match what the apps actually inject — these assignments do, at compile time. + */ +describe('vendor Ledger contracts', () => { + it('are satisfied by the packages the apps inject', () => { + const transport: ILedgerTransport = {} as Transport; + const app: ILedgerCasperApp = {} as CasperApp; + + expect([transport, app]).toHaveLength(2); + }); +}); diff --git a/src/data/repositories/casperTransactions/casperTransactions.test.ts b/src/data/repositories/casperTransactions/casperTransactions.test.ts new file mode 100644 index 0000000..f99172c --- /dev/null +++ b/src/data/repositories/casperTransactions/casperTransactions.test.ts @@ -0,0 +1,736 @@ +import { + Conversions, + KeyAlgorithm, + makeCsprTransferDeploy, + PrivateKey, + Transaction, +} from 'casper-js-sdk'; +import { + AuctionManagerEntryPointType, + CasperTransactionsError, + CSPR_COIN, + GrpcUrl, + IBuiltDexTransaction, + ICasperLedgerService, + ICasperSigner, + INft, + IToken, + LedgerError, + LedgerEventStatus, +} from '../../../domain'; +import { createLedgerSigner, createPrivateKeySigner } from '../../signers'; +import * as txBuildersModule from '../../../utils/casperSdk/tx-builders'; +import { CasperTransactionsRepository } from './index'; + +const mockGetStatus = jest.fn(); +const mockPutTransaction = jest.fn(); +const mockPutDeploy = jest.fn(); +const mockSetReferrer = jest.fn(); +const mockSetCustomHeaders = jest.fn(); +const mockHttpHandlerCtor = jest.fn(); + +jest.mock('casper-js-sdk', () => ({ + ...jest.requireActual('casper-js-sdk'), + HttpHandler: class { + constructor(...args: unknown[]) { + mockHttpHandlerCtor(...args); + } + setReferrer = mockSetReferrer; + setCustomHeaders = mockSetCustomHeaders; + }, + RpcClient: class { + getStatus = mockGetStatus; + putTransaction = mockPutTransaction; + putDeploy = mockPutDeploy; + }, +})); + +const nodeStatus = (isoDate: string, apiVersion = '2.0.0') => ({ + apiVersion, + lastProgress: { toDate: () => new Date(isoDate) }, +}); + +const sender = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); +const recipient = PrivateKey.generate(KeyAlgorithm.SECP256K1).publicKey.toHex(); +const newValidator = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); + +const generateKeysFixture = () => { + const pk = PrivateKey.generate(KeyAlgorithm.ED25519); + return { + publicKeyHex: pk.publicKey.toHex(), + secretKeyBase64: Conversions.encodeBase64(pk.toBytes()), + }; +}; + +const deployFixture = () => + makeCsprTransferDeploy({ + chainName: 'casper-test', + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + transferAmount: '2500000000', + timestamp: '2026-01-01T00:00:00.000Z', + }); + +const txFixture = () => Transaction.fromDeploy(deployFixture()); + +const makeFakeSigner = (publicKeyHex: string) => { + const calls: { fallbackDeploy?: unknown }[] = []; + const signer: ICasperSigner = { + publicKeyHex, + signTransaction: jest.fn(async () => ({ + signature: new Uint8Array([1]), + signatureWithPrefix: new Uint8Array([2, 1]), + })), + getSignedTransaction: jest.fn(async (tx, options) => { + calls.push({ fallbackDeploy: options?.fallbackDeploy }); + return tx; + }), + signMessage: jest.fn(async () => new Uint8Array([7])), + }; + return { signer, calls }; +}; + +const CONTRACT_PACKAGE_HASH = 'b2ec4f982efa8643c979cb3ab42ad1a18851c2e6f91804cd3e65c079679bdc59'; + +const cep18Token: IToken = { + ...CSPR_COIN, + id: 'cep18-token', + contractPackageHash: CONTRACT_PACKAGE_HASH, + contractHash: CONTRACT_PACKAGE_HASH, + decimals: 6, + symbol: 'CEP18', + isNative: false, +}; + +const nftFixture = (tokenIdType: INft['tokenIdType']): INft => ({ + id: 'nft-1', + tokenId: '7', + tokenIdType, + trackingId: 'track-1', + standard: 'CEP78', + contractPackageHash: CONTRACT_PACKAGE_HASH, + contractPackageIcon: null, + contactName: 'Test NFT Contract', + ownerReverseLookupMode: false, + metadata: {}, + previewUrl: null, + proxyPreviewUrl: null, + timestamp: '2026-01-01T00:00:00.000Z', +}); + +describe('CasperTransactionsRepository rpc plumbing', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses node time when the node is ahead of local-2s', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:09.000Z')); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect(repo.getDateForTransaction('mainnet')).resolves.toBe('2026-01-01T00:00:09.000Z'); + jest.useRealTimers(); + }); + + it('falls back to local-2s when the node lags or errors', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:01.000Z')); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect(repo.getDateForTransaction('mainnet')).resolves.toBe('2026-01-01T00:00:08.000Z'); + mockGetStatus.mockRejectedValue(new Error('down')); + await expect(repo.getDateForTransaction('mainnet')).resolves.toBe('2026-01-01T00:00:08.000Z'); + jest.useRealTimers(); + }); + + it('logs the node-time read that sent it back to the device clock', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + const log = { + log: jest.fn(), + logGroup: jest.fn(), + logGroupEnd: jest.fn(), + reportError: jest.fn(), + }; + mockGetStatus.mockRejectedValue(new Error('down')); + const repo = new CasperTransactionsRepository(GrpcUrl, {}, log); + + await expect(repo.getDateForTransaction('mainnet')).resolves.toBe('2026-01-01T00:00:08.000Z'); + + expect(log.reportError).toHaveBeenCalledWith( + expect.any(Error), + expect.stringContaining('getDateForTransaction'), + ); + jest.useRealTimers(); + }); + + it('getNetworkApiVersion returns apiVersion and wraps RPC failures', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z', '1.5.8')); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect(repo.getNetworkApiVersion('testnet')).resolves.toBe('1.5.8'); + + mockGetStatus.mockRejectedValue(new Error('down')); + await expect(repo.getNetworkApiVersion('testnet')).rejects.toMatchObject({ + name: 'CasperTransactionsError', + type: 'getNetworkApiVersion', + }); + }); + + it("default rpc options: 'fetch' handler + setReferrer, no Referer header", async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + await new CasperTransactionsRepository(GrpcUrl).getNetworkApiVersion('mainnet'); + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(GrpcUrl.mainnet, 'fetch'); + expect(mockSetReferrer).toHaveBeenCalledWith('https://casperwallet.io'); + expect(mockSetCustomHeaders).not.toHaveBeenCalled(); + }); + + it("mobile rpc options: 'axios' handler + literal Referer header (+auth)", async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const repo = new CasperTransactionsRepository(GrpcUrl, { + handlerType: 'axios', + referrerMode: 'referer-header', + authorizationHeader: 'token', + }); + await repo.getNetworkApiVersion('mainnet'); + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(GrpcUrl.mainnet, 'axios'); + expect(mockSetCustomHeaders).toHaveBeenCalledWith({ + Referer: 'https://casperwallet.io', + Authorization: 'token', + }); + expect(mockSetReferrer).not.toHaveBeenCalled(); + }); + + it('default referrer mode still merges an Authorization header, without a Referer one', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const repo = new CasperTransactionsRepository(GrpcUrl, { authorizationHeader: 'token' }); + await repo.getNetworkApiVersion('mainnet'); + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(GrpcUrl.mainnet, 'fetch'); + expect(mockSetReferrer).toHaveBeenCalledWith('https://casperwallet.io'); + expect(mockSetCustomHeaders).toHaveBeenCalledWith({ Authorization: 'token' }); + }); +}); + +describe('sendSignedTransaction', () => { + beforeEach(() => jest.clearAllMocks()); + + it('2.x uses putTransaction and returns the tx hash', async () => { + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'aabb' } }); + const repo = new CasperTransactionsRepository(GrpcUrl); + const tx = txFixture(); + await expect( + repo.sendSignedTransaction({ + transaction: tx, + network: 'mainnet', + casperNetworkApiVersion: '2.0.0', + }), + ).resolves.toBe('aabb'); + expect(mockPutDeploy).not.toHaveBeenCalled(); + }); + + it('2.x: InvalidDeployError when putTransaction returns falsy', async () => { + mockPutTransaction.mockResolvedValue(null); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendSignedTransaction({ + transaction: txFixture(), + network: 'mainnet', + casperNetworkApiVersion: '2.0.0', + }), + ).rejects.toThrow('errors:deploy-rpc-error'); + }); + + it('1.x uses putDeploy; InvalidDeployError when RPC returns falsy or tx has no deploy', async () => { + mockPutDeploy.mockResolvedValue({ deployHash: { toHex: () => 'ccdd' } }); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendSignedTransaction({ + transaction: txFixture(), + network: 'mainnet', + casperNetworkApiVersion: '1.5.8', + }), + ).resolves.toBe('ccdd'); + + mockPutDeploy.mockResolvedValue(null); + await expect( + repo.sendSignedTransaction({ + transaction: txFixture(), + network: 'mainnet', + casperNetworkApiVersion: '1.5.8', + }), + ).rejects.toThrow('errors:deploy-rpc-error'); + }); + + it('1.x: InvalidDeployError when the transaction has no deploy to submit', async () => { + const { transaction: v1Tx } = txBuildersModule.buildCsprTransferTransactions( + { + network: 'mainnet', + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + transferAmountMotes: '2500000000', + timestamp: '2026-01-01T00:00:00.000Z', + }, + '2.0.0', + ); + expect(v1Tx.getDeploy()).toBeFalsy(); + + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendSignedTransaction({ + transaction: v1Tx, + network: 'mainnet', + casperNetworkApiVersion: '1.5.8', + }), + ).rejects.toThrow('errors:deploy-rpc-error'); + expect(mockPutDeploy).not.toHaveBeenCalled(); + }); + + it('carries the node error through sendSignedTransaction', async () => { + const nodeError = Object.assign(new Error('Code: 500, err: deploy error'), { + statusCode: 500, + sourceErr: Object.assign(new Error('invalid deploy'), { code: -32008, data: 'bad hash' }), + }); + mockPutTransaction.mockRejectedValue(nodeError); + const repo = new CasperTransactionsRepository(GrpcUrl); + + await expect( + repo.sendSignedTransaction({ + transaction: txFixture(), + network: 'mainnet', + casperNetworkApiVersion: '2.0.0', + }), + ).rejects.toMatchObject({ + name: 'CasperTransactionsError', + sourceError: nodeError, + }); + }); +}); + +describe('signTransaction / signMessage', () => { + beforeEach(() => jest.clearAllMocks()); + + it('rejects with AlreadySignedError before invoking the signer when the key already approved', async () => { + const keys = generateKeysFixture(); + const signed = await createPrivateKeySigner(keys).getSignedTransaction(txFixture()); + const { signer } = makeFakeSigner(keys.publicKeyHex.toUpperCase()); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect(repo.signTransaction({ transaction: signed, signer })).rejects.toThrow( + 'errors:already-signed', + ); + expect(signer.signTransaction).not.toHaveBeenCalled(); + }); + + it('returns the signer pair for a fresh transaction; signMessage delegates raw bytes', async () => { + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect(repo.signTransaction({ transaction: txFixture(), signer })).resolves.toEqual({ + signature: new Uint8Array([1]), + signatureWithPrefix: new Uint8Array([2, 1]), + }); + await expect(repo.signMessage({ message: 'm', signer })).resolves.toEqual(new Uint8Array([7])); + }); +}); + +describe('composed sends', () => { + beforeEach(() => jest.clearAllMocks()); + + it('sendTokenTransfer (native) converts decimals, passes fallback deploy to the signer', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'ee' } }); + // The SDK CLValue-encodes the transfer amount as bytes, so the motes value is asserted + // through the builder spy rather than the serialized transaction. + const buildSpy = jest.spyOn(txBuildersModule, 'buildCsprTransferTransactions'); + const { signer, calls } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '2.5', + paymentAmount: '0.1', + memo: '42', + signer, + }), + ).resolves.toBe('ee'); + expect(calls[0].fallbackDeploy).toBeTruthy(); // fallback always provided + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ transferAmountMotes: '2500000000', memo: '42' }), + '2.0.0', + ); + }); + + it('sendTokenTransfer (CEP-18) converts amount with token.decimals and payment with CSPR decimals', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'cep18' } }); + const buildSpy = jest.spyOn(txBuildersModule, 'buildCep18TransferTransactions'); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: cep18Token, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1.5', + paymentAmount: '2.5', + signer, + }), + ).resolves.toBe('cep18'); + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ + contractPackageHash: cep18Token.contractPackageHash, + transferAmountMotes: '1500000', + paymentAmountMotes: '2500000000', + }), + '2.0.0', + ); + }); + + describe('sendNftTransfer', () => { + it('uint tokenIdType sets tokenId, leaves tokenHash unset', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'nft-uint' } }); + const buildSpy = jest.spyOn(txBuildersModule, 'buildNftTransferTransactions'); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendNftTransfer({ + nft: nftFixture('uint'), + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + paymentAmount: '0.1', + signer, + }), + ).resolves.toBe('nft-uint'); + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: '7', tokenHash: undefined }), + '2.0.0', + ); + }); + + it('hash tokenIdType sets tokenHash, leaves tokenId unset', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'nft-hash' } }); + const buildSpy = jest.spyOn(txBuildersModule, 'buildNftTransferTransactions'); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendNftTransfer({ + nft: nftFixture('hash'), + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + paymentAmount: '0.1', + signer, + }), + ).resolves.toBe('nft-hash'); + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: undefined, tokenHash: '7' }), + '2.0.0', + ); + }); + }); + + describe('sendDelegation', () => { + it.each(['DELEGATE', 'UNDELEGATE', 'REDELEGATE'])( + 'builds via the auction manager builder and converts stake/payment with CSPR decimals — %s', + async entryPoint => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue({ + transactionHash: { toHex: () => `hash-${entryPoint}` }, + }); + const buildSpy = jest.spyOn(txBuildersModule, 'buildAuctionManagerTransactions'); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendDelegation({ + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + entryPoint, + stake: '10', + paymentAmount: '2.5', + validatorPublicKeyHex: recipient, + newValidatorPublicKeyHex: entryPoint === 'REDELEGATE' ? newValidator : undefined, + signer, + }), + ).resolves.toBe(`hash-${entryPoint}`); + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ + entryPoint, + delegatorPublicKeyHex: sender, + amountMotes: '10000000000', + paymentAmountMotes: '2500000000', + }), + '2.0.0', + ); + }, + ); + }); + + it('feeds getDateForTransaction result into the builder', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:09.000Z')); + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'ts' } }); + const buildSpy = jest.spyOn(txBuildersModule, 'buildCsprTransferTransactions'); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1', + paymentAmount: '0.1', + signer, + }); + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ timestamp: '2026-01-01T00:00:09.000Z' }), + '2.0.0', + ); + jest.useRealTimers(); + }); + + describe('error wrapping', () => { + it('wraps a plain error thrown inside sendTokenTransfer as CasperTransactionsError', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const { signer } = makeFakeSigner(sender); + (signer.getSignedTransaction as jest.Mock).mockRejectedValueOnce(new Error('signer offline')); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1', + paymentAmount: '0.1', + signer, + }), + ).rejects.toMatchObject({ + name: 'CasperTransactionsError', + type: 'sendTokenTransfer', + message: 'signer offline', + }); + }); + + it('rethrows a nested CasperTransactionsError untouched (not relabeled)', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const { signer } = makeFakeSigner(sender); + (signer.getSignedTransaction as jest.Mock).mockRejectedValueOnce( + new CasperTransactionsError(new Error('unrelated failure'), 'signMessage'), + ); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1', + paymentAmount: '0.1', + signer, + }), + ).rejects.toMatchObject({ name: 'CasperTransactionsError', type: 'signMessage' }); + }); + + it('rethrows InvalidDeployError from a nested submit untouched (not relabeled)', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + mockPutTransaction.mockResolvedValue(null); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1', + paymentAmount: '0.1', + signer, + }), + ).rejects.toMatchObject({ name: 'DeploysRepositoryError', type: 'invalidDeploy' }); + }); + + it('rethrows a LedgerError from a nested signer untouched (not wrapped)', async () => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const { signer } = makeFakeSigner(sender); + const ledgerError = new LedgerError({ status: LedgerEventStatus.SignatureCanceled }); + (signer.getSignedTransaction as jest.Mock).mockRejectedValueOnce(ledgerError); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendTokenTransfer({ + token: { ...CSPR_COIN }, + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + amount: '1', + paymentAmount: '0.1', + signer, + }), + ).rejects.toBe(ledgerError); + }); + + it.each([ + [ + 'sendNftTransfer', + (repo: CasperTransactionsRepository, signer: ICasperSigner) => + repo.sendNftTransfer({ + nft: nftFixture('uint'), + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + toPublicKeyHex: recipient, + paymentAmount: '15', + signer, + }), + ], + [ + 'sendDelegation', + (repo: CasperTransactionsRepository, signer: ICasperSigner) => + repo.sendDelegation({ + network: 'testnet', + casperNetworkApiVersion: '2.0.0', + entryPoint: 'DELEGATE' as AuctionManagerEntryPointType, + stake: '500', + paymentAmount: '2.5', + validatorPublicKeyHex: recipient, + newValidatorPublicKeyHex: newValidator, + signer, + }), + ], + [ + 'signTransaction', + (repo: CasperTransactionsRepository, signer: ICasperSigner) => + repo.signTransaction({ transaction: txFixture(), signer }), + ], + [ + 'signMessage', + (repo: CasperTransactionsRepository, signer: ICasperSigner) => + repo.signMessage({ message: 'hello', signer }), + ], + ])('tags a plain error thrown inside %s with that method name', async (type, invoke) => { + mockGetStatus.mockResolvedValue(nodeStatus('2026-01-01T00:00:00.000Z')); + const { signer } = makeFakeSigner(sender); + const boom = new Error('signer offline'); + (signer.getSignedTransaction as jest.Mock).mockRejectedValue(boom); + (signer.signTransaction as jest.Mock).mockRejectedValue(boom); + (signer.signMessage as jest.Mock).mockRejectedValue(boom); + + await expect(invoke(new CasperTransactionsRepository(GrpcUrl), signer)).rejects.toMatchObject( + { + name: 'CasperTransactionsError', + type, + message: 'signer offline', + }, + ); + }); + }); +}); + +const makeOldAppLedgerService = () => ({ + getSignedTransaction: jest.fn( + async ( + tx: Transaction, + _account: unknown, + fallbackTxFromDeploy?: Transaction, + ): Promise => { + if (!fallbackTxFromDeploy) { + throw new LedgerError({ status: LedgerEventStatus.TransactionForOldAppVersion }); + } + + return fallbackTxFromDeploy; + }, + ), +}); + +describe('sendDexTransaction', () => { + beforeEach(() => jest.clearAllMocks()); + + const dexBuilt = (over: Partial): IBuiltDexTransaction => + ({ + kind: 'swap', + entryPoint: 'swap_exact_cspr_for_tokens', + paymentMotes: '15000000000', + ...over, + }) as IBuiltDexTransaction; + + it('signs and submits a V1 artifact via putTransaction, no fallback option', async () => { + mockPutTransaction.mockResolvedValue({ transactionHash: { toHex: () => 'a1' } }); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendDexTransaction({ + built: dexBuilt({ transaction: txFixture() }), + network: 'mainnet', + signer, + }), + ).resolves.toBe('a1'); + expect(mockPutDeploy).not.toHaveBeenCalled(); + expect((signer.getSignedTransaction as jest.Mock).mock.calls[0][1]).toBeUndefined(); + }); + + it('wraps, signs and submits a deploy artifact via putDeploy', async () => { + mockPutDeploy.mockResolvedValue({ deployHash: { toHex: () => 'b2' } }); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + const deploy = deployFixture(); + await expect( + repo.sendDexTransaction({ built: dexBuilt({ deploy }), network: 'mainnet', signer }), + ).resolves.toBe('b2'); + expect(mockPutTransaction).not.toHaveBeenCalled(); + expect((signer.getSignedTransaction as jest.Mock).mock.calls[0][1]).toEqual({ + fallbackDeploy: deploy, + }); + }); + + it('a Ledger on a pre-v3 app signs the deploy artifact instead of being told to update', async () => { + mockPutDeploy.mockResolvedValue({ deployHash: { toHex: () => 'c3' } }); + const deploy = deployFixture(); + const ledgerService = makeOldAppLedgerService(); + const signer = createLedgerSigner({ + service: ledgerService as unknown as ICasperLedgerService, + publicKeyHex: sender, + }); + const repo = new CasperTransactionsRepository(GrpcUrl); + + await expect( + repo.sendDexTransaction({ built: dexBuilt({ deploy }), network: 'mainnet', signer }), + ).resolves.toBe('c3'); + }); + + it('V1 artifact: InvalidDeployError (not wrapped) when putTransaction resolves falsy', async () => { + mockPutTransaction.mockResolvedValue(null); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendDexTransaction({ + built: dexBuilt({ transaction: txFixture() }), + network: 'mainnet', + signer, + }), + ).rejects.toMatchObject({ name: 'DeploysRepositoryError', message: 'errors:deploy-rpc-error' }); + }); + + it('deploy artifact: InvalidDeployError (not wrapped) when putDeploy resolves falsy', async () => { + mockPutDeploy.mockResolvedValue(null); + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + const deploy = deployFixture(); + await expect( + repo.sendDexTransaction({ built: dexBuilt({ deploy }), network: 'mainnet', signer }), + ).rejects.toMatchObject({ name: 'DeploysRepositoryError', message: 'errors:deploy-rpc-error' }); + }); + + it('rejects a malformed artifact and wraps plain errors with its own type', async () => { + const { signer } = makeFakeSigner(sender); + const repo = new CasperTransactionsRepository(GrpcUrl); + await expect( + repo.sendDexTransaction({ built: dexBuilt({}), network: 'mainnet', signer }), + ).rejects.toThrow('errors:deploy-rpc-error'); + + (signer.getSignedTransaction as jest.Mock).mockRejectedValueOnce(new Error('nope')); + await expect( + repo.sendDexTransaction({ + built: dexBuilt({ transaction: txFixture() }), + network: 'mainnet', + signer, + }), + ).rejects.toMatchObject({ name: 'CasperTransactionsError', type: 'sendDexTransaction' }); + }); +}); diff --git a/src/data/repositories/casperTransactions/index.ts b/src/data/repositories/casperTransactions/index.ts new file mode 100644 index 0000000..0395124 --- /dev/null +++ b/src/data/repositories/casperTransactions/index.ts @@ -0,0 +1,310 @@ +import { RpcClient, Transaction } from 'casper-js-sdk'; +import { isBefore, sub } from 'date-fns'; +import { + AlreadySignedError, + CasperNetwork, + CasperTransactionsError, + CasperTransactionsErrorType, + CSPR_COIN, + ICasperRpcOptions, + ICasperTransactionsRepository, + InvalidDeployError, + isCasperTransactionsError, + ISendDelegationParams, + ISendDexTransactionParams, + ISendNftTransferParams, + ISendSignedTransactionParams, + ISendTokenTransferParams, + ISignMessageParams, + ISignTransactionParams, + ISignTransactionResponse, + ILogger, + LedgerError, +} from '../../../domain'; +import { getBlockchainAmount } from '../../../utils/common'; +import { isTransactionSignedBy } from '../../../utils/transactions'; +import { createCasperRpcClient } from '../../../utils/casperSdk/rpcClient'; +import { + buildAuctionManagerTransactions, + buildCep18TransferTransactions, + buildCsprTransferTransactions, + buildNftTransferTransactions, + IBuiltCasperTransaction, +} from '../../../utils/casperSdk/tx-builders'; + +export class CasperTransactionsRepository implements ICasperTransactionsRepository { + constructor( + private _grpcUrl: Record, + private _rpcOptions: ICasperRpcOptions = {}, + private _log?: ILogger, + ) {} + + async getNetworkApiVersion(network: CasperNetwork): Promise { + try { + const resp = await this._createRpcClient(network).getStatus(); + + return resp.apiVersion; + } catch (e) { + this._processError(e, 'getNetworkApiVersion'); + } + } + + protected _createRpcClient(network: CasperNetwork): RpcClient { + return createCasperRpcClient(this._grpcUrl[network], this._rpcOptions); + } + + /** + * Node time, falling back to the device clock (logged) when the node cannot be read. Every + * transfer and delegation is timestamped from this, so a skewed device clock has the node + * reject them as future-dated or expire them early. + */ + async getDateForTransaction(network: CasperNetwork): Promise { + const defaultDate = sub(new Date(), { seconds: 2 }); + + try { + const resp = await this._createRpcClient(network).getStatus(); + const nodeDate = resp.lastProgress.toDate(); + + return isBefore(nodeDate, defaultDate) ? defaultDate.toISOString() : nodeDate.toISOString(); + } catch (e) { + this._log?.reportError( + e, + 'CasperTransactionsRepository.getDateForTransaction: falling back to the device clock', + ); + + return defaultDate.toISOString(); + } + } + + async sendTokenTransfer(params: ISendTokenTransferParams): Promise { + try { + const { + token, + network, + casperNetworkApiVersion, + toPublicKeyHex, + amount, + paymentAmount, + memo, + signer, + } = params; + const timestamp = await this.getDateForTransaction(network); + + const built = token.isNative + ? buildCsprTransferTransactions( + { + network, + senderPublicKeyHex: signer.publicKeyHex, + recipientPublicKeyHex: toPublicKeyHex, + transferAmountMotes: getBlockchainAmount(amount, CSPR_COIN.decimals), + memo: memo ?? undefined, + timestamp, + }, + casperNetworkApiVersion, + ) + : buildCep18TransferTransactions( + { + network, + contractPackageHash: token.contractPackageHash, + senderPublicKeyHex: signer.publicKeyHex, + recipientPublicKeyHex: toPublicKeyHex, + transferAmountMotes: getBlockchainAmount(amount, token.decimals), + paymentAmountMotes: getBlockchainAmount(paymentAmount, CSPR_COIN.decimals), + timestamp, + }, + casperNetworkApiVersion, + ); + + return await this._signAndSubmit(built, params); + } catch (e) { + this._processError(e, 'sendTokenTransfer'); + } + } + + async sendNftTransfer(params: ISendNftTransferParams): Promise { + try { + const { nft, network, casperNetworkApiVersion, toPublicKeyHex, paymentAmount, signer } = + params; + const timestamp = await this.getDateForTransaction(network); + + const built = buildNftTransferTransactions( + { + network, + contractPackageHash: nft.contractPackageHash, + nftStandard: nft.standard, + senderPublicKeyHex: signer.publicKeyHex, + recipientPublicKeyHex: toPublicKeyHex, + paymentAmountMotes: getBlockchainAmount(paymentAmount, CSPR_COIN.decimals), + tokenId: nft.tokenIdType === 'uint' ? nft.tokenId : undefined, + tokenHash: nft.tokenIdType === 'hash' ? nft.tokenId : undefined, + timestamp, + }, + casperNetworkApiVersion, + ); + + return await this._signAndSubmit(built, params); + } catch (e) { + this._processError(e, 'sendNftTransfer'); + } + } + + async sendDelegation(params: ISendDelegationParams): Promise { + try { + const { + network, + casperNetworkApiVersion, + entryPoint, + stake, + paymentAmount, + validatorPublicKeyHex, + newValidatorPublicKeyHex, + signer, + } = params; + const timestamp = await this.getDateForTransaction(network); + + const built = buildAuctionManagerTransactions( + { + network, + entryPoint, + delegatorPublicKeyHex: signer.publicKeyHex, + validatorPublicKeyHex, + newValidatorPublicKeyHex, + amountMotes: getBlockchainAmount(stake, CSPR_COIN.decimals), + paymentAmountMotes: getBlockchainAmount(paymentAmount, CSPR_COIN.decimals), + timestamp, + }, + casperNetworkApiVersion, + ); + + return await this._signAndSubmit(built, params); + } catch (e) { + this._processError(e, 'sendDelegation'); + } + } + + async sendSignedTransaction({ + transaction, + network, + casperNetworkApiVersion, + }: ISendSignedTransactionParams): Promise { + try { + const rpcClient = this._createRpcClient(network); + + if (casperNetworkApiVersion.startsWith('2.')) { + const resp = await rpcClient.putTransaction(transaction); + + if (!resp) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + return resp.transactionHash.toHex(); + } + + const deploy = transaction.getDeploy(); + + if (!deploy) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + const resp = await rpcClient.putDeploy(deploy); + + if (!resp) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + return resp.deployHash.toHex(); + } catch (e) { + this._processError(e, 'sendSignedTransaction'); + } + } + + async sendDexTransaction({ built, network, signer }: ISendDexTransactionParams): Promise { + try { + const rpcClient = this._createRpcClient(network); + + if (built.deploy) { + const signed = await signer.getSignedTransaction(Transaction.fromDeploy(built.deploy), { + fallbackDeploy: built.deploy, + }); + const deploy = signed.getDeploy(); + + if (!deploy) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + const resp = await rpcClient.putDeploy(deploy); + + if (!resp) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + return resp.deployHash.toHex(); + } + + if (!built.transaction) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + const signed = await signer.getSignedTransaction(built.transaction); + const resp = await rpcClient.putTransaction(signed); + + if (!resp) { + throw new InvalidDeployError('errors:deploy-rpc-error'); + } + + return resp.transactionHash.toHex(); + } catch (e) { + this._processError(e, 'sendDexTransaction'); + } + } + + async signTransaction({ + transaction, + signer, + }: ISignTransactionParams): Promise { + try { + if (isTransactionSignedBy(transaction, signer.publicKeyHex)) { + throw new AlreadySignedError(); + } + + return await signer.signTransaction(transaction); + } catch (e) { + this._processError(e, 'signTransaction'); + } + } + + async signMessage({ message, signer }: ISignMessageParams): Promise { + try { + return await signer.signMessage(message); + } catch (e) { + this._processError(e, 'signMessage'); + } + } + + private async _signAndSubmit( + built: IBuiltCasperTransaction, + params: Pick, + ): Promise { + const signedTx = await params.signer.getSignedTransaction(built.transaction, { + fallbackDeploy: built.fallbackDeploy, + }); + + return this.sendSignedTransaction({ + transaction: signedTx, + network: params.network, + casperNetworkApiVersion: params.casperNetworkApiVersion, + }); + } + + protected _processError(e: unknown, type: CasperTransactionsErrorType): never { + if ( + isCasperTransactionsError(e) || + e instanceof InvalidDeployError || + e instanceof LedgerError + ) { + throw e; + } + + throw new CasperTransactionsError(e, type); + } +} diff --git a/src/data/repositories/deploys/deploys.test.ts b/src/data/repositories/deploys/deploys.test.ts index f941631..8dbd53b 100644 --- a/src/data/repositories/deploys/deploys.test.ts +++ b/src/data/repositories/deploys/deploys.test.ts @@ -144,4 +144,40 @@ describe('DeploysRepository', () => { ).toEqual(EMPTY_PAGINATED_RESPONSE); }); }); + + describe('withProxyHeader', () => { + const params = { + network: 'mainnet', + activePublicKey: PUBLIC_KEY, + page: 1, + withProxyHeader: false, + } as const; + const emptyPage = { item_count: 0, page_count: 0, pages: [], data: [] }; + + const cases: [string, unknown, (repo: DeploysRepository) => Promise][] = [ + ['getDeploys', emptyPage, repo => repo.getDeploys(params)], + ['getCsprTransferDeploys', emptyPage, repo => repo.getCsprTransferDeploys(params)], + ['getCep18TransferDeploys', emptyPage, repo => repo.getCep18TransferDeploys(params)], + ['getTransactionsFeed', emptyPage, repo => repo.getTransactionsFeed(params)], + [ + 'getSingleDeploy', + { data: makeCloudDeploy() }, + repo => repo.getSingleDeploy({ ...params, deployHash: 'd'.repeat(64) }), + ], + ]; + + it.each(cases)('%s keeps the header off the nested accounts lookup', async (_, resp, call) => { + const { http, repo, accountInfoRepository } = buildRepo(); + http.get.mockResolvedValue(resp); + + await call(repo); + + expect(http.get).toHaveBeenCalledWith( + expect.not.objectContaining({ headers: expect.anything() }), + ); + expect(accountInfoRepository.getAccountsInfo).toHaveBeenCalledWith( + expect.objectContaining({ withProxyHeader: false }), + ); + }); + }); }); diff --git a/src/data/repositories/deploys/index.ts b/src/data/repositories/deploys/index.ts index 46975b8..a8ce5a3 100644 --- a/src/data/repositories/deploys/index.ts +++ b/src/data/repositories/deploys/index.ts @@ -70,6 +70,7 @@ export class DeploysRepository implements IDeploysRepository { await this._accountInfoRepository.getAccountsInfo({ network, accountHashes, + withProxyHeader, }); return { @@ -122,6 +123,7 @@ export class DeploysRepository implements IDeploysRepository { await this._accountInfoRepository.getAccountsInfo({ network, accountHashes, + withProxyHeader, }); return { @@ -166,6 +168,7 @@ export class DeploysRepository implements IDeploysRepository { await this._accountInfoRepository.getAccountsInfo({ network, accountHashes: [...deployHashes, ...resultsHashes], + withProxyHeader, }); if (resp?.data) { @@ -225,6 +228,7 @@ export class DeploysRepository implements IDeploysRepository { await this._accountInfoRepository.getAccountsInfo({ network, accountHashes, + withProxyHeader, }); return { @@ -280,6 +284,7 @@ export class DeploysRepository implements IDeploysRepository { await this._accountInfoRepository.getAccountsInfo({ network, accountHashes, + withProxyHeader, }); await this._accountInfoRepository.getAccountInfoFromTransactionsFeed(feedItems, network); diff --git a/src/data/repositories/dex/builders.test.ts b/src/data/repositories/dex/builders.test.ts new file mode 100644 index 0000000..b27c392 --- /dev/null +++ b/src/data/repositories/dex/builders.test.ts @@ -0,0 +1,894 @@ +import { Args, Deploy, PublicKey, RpcClient, Transaction } from 'casper-js-sdk'; + +import { + createContractDeploy, + createContractPackageCallTransaction, + createSessionWasmTransaction, + createWASMContractDeploy, +} from './transactionBuilders'; +import * as transactionBuilders from './transactionBuilders'; +import { DexContractRepository } from './index'; +import { + CasperSdkNetworkName, + DEX_PAYMENT_AMOUNT, + DEX_TRANSACTION_TTL_MS, + DexError, + IDexTokenWithAmount, + MAX_DEADLINE, + MAX_SLIPPAGE, + SwapQuoteType, + TradeContractPackageHash, + WrappedCsprContractPackageHash, +} from '../../../domain'; + +const PUBLIC_KEY = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; +const CONTRACT_PACKAGE_HASH = '04a11a367e708c52557930c4e9c1301f4465100d1b1b6d0a62b48d3e32402867'; +const CHAIN_NAME = CasperSdkNetworkName.testnet; +const PAYMENT_MOTES = '5000000000'; + +const makeRpcClient = (apiVersion: string | Error): RpcClient => + ({ + getStatus: + apiVersion instanceof Error + ? jest.fn().mockRejectedValue(apiVersion) + : jest.fn().mockResolvedValue({ apiVersion }), + }) as unknown as RpcClient; + +describe('transactionBuilders', () => { + describe('createContractPackageCallTransaction', () => { + it('returns a Transaction targeting the package hash, entry point, TTL and chain name', () => { + const tx = createContractPackageCallTransaction({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + entryPoint: 'swap', + paymentMotes: PAYMENT_MOTES, + runtimeArgs: Args.fromMap({}), + contractPackageHash: CONTRACT_PACKAGE_HASH, + gasPriceTolerance: 1, + }); + + expect(tx).toBeInstanceOf(Transaction); + expect(tx.chainName).toBe(CHAIN_NAME); + expect(tx.ttl.toMilliseconds()).toBe(DEX_TRANSACTION_TTL_MS); + expect(tx.entryPoint.customEntryPoint).toBe('swap'); + expect(tx.target.stored?.id.byPackageHash?.addr.toHex()).toBe(CONTRACT_PACKAGE_HASH); + }); + + it('exposes the payment amount', () => { + const tx = createContractPackageCallTransaction({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + entryPoint: 'swap', + paymentMotes: PAYMENT_MOTES, + runtimeArgs: Args.fromMap({}), + contractPackageHash: CONTRACT_PACKAGE_HASH, + gasPriceTolerance: 1, + }); + + expect(tx.pricingMode.paymentLimited?.paymentAmount.toString()).toBe(PAYMENT_MOTES); + }); + }); + + describe('createContractDeploy', () => { + it('returns a Deploy with the same targeting, TTL and chain name', () => { + const deploy = createContractDeploy({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + entryPoint: 'swap', + runtimeArgs: Args.fromMap({}), + contractPackageHash: CONTRACT_PACKAGE_HASH, + gasPriceTolerance: 1, + }); + + expect(deploy).toBeInstanceOf(Deploy); + expect(deploy.header.chainName).toBe(CHAIN_NAME); + expect(deploy.header.ttl.toMilliseconds()).toBe(DEX_TRANSACTION_TTL_MS); + expect(deploy.session.storedVersionedContractByHash?.entryPoint).toBe('swap'); + expect(deploy.session.storedVersionedContractByHash?.hash.hash.toHex()).toBe( + CONTRACT_PACKAGE_HASH, + ); + }); + + it('exposes the payment amount', () => { + const deploy = createContractDeploy({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + entryPoint: 'swap', + runtimeArgs: Args.fromMap({}), + contractPackageHash: CONTRACT_PACKAGE_HASH, + gasPriceTolerance: 1, + }); + + expect(deploy.payment.moduleBytes?.args.args.get('amount')?.toString()).toBe(PAYMENT_MOTES); + }); + }); + + describe('createSessionWasmTransaction', () => { + const wasmBinary = new Uint8Array([1, 2, 3, 4]); + + it('returns a Transaction carrying the wasm session (Casper 2.x api)', async () => { + const tx = await createSessionWasmTransaction({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + wasmBinary, + runtimeArgs: Args.fromMap({}), + gasPriceTolerance: 1, + rpcClient: makeRpcClient('2.0.0'), + }); + + expect(tx).toBeInstanceOf(Transaction); + expect(tx.chainName).toBe(CHAIN_NAME); + expect(tx.ttl.toMilliseconds()).toBe(DEX_TRANSACTION_TTL_MS); + expect(tx.target.session?.moduleBytes).toEqual(wasmBinary); + expect(tx.target.session?.isInstallUpgrade).toBe(true); + expect(tx.pricingMode.paymentLimited?.paymentAmount.toString()).toBe(PAYMENT_MOTES); + }); + + it('returns a Transaction carrying the wasm session (legacy 1.x api)', async () => { + const tx = await createSessionWasmTransaction({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + wasmBinary, + runtimeArgs: Args.fromMap({}), + gasPriceTolerance: 1, + rpcClient: makeRpcClient('1.5.6'), + }); + + expect(tx).toBeInstanceOf(Transaction); + expect(tx.target.session?.moduleBytes).toEqual(wasmBinary); + }); + + it('rejects when the RPC getStatus call fails', async () => { + await expect( + createSessionWasmTransaction({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + wasmBinary, + runtimeArgs: Args.fromMap({}), + gasPriceTolerance: 1, + rpcClient: makeRpcClient(new Error('rpc down')), + }), + ).rejects.toThrow('rpc down'); + }); + }); + + describe('createWASMContractDeploy', () => { + const wasmBinary = new Uint8Array([1, 2, 3, 4]); + + it('returns a Deploy carrying the wasm, TTL and chain name', () => { + const deploy = createWASMContractDeploy({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + wasmBinary, + runtimeArgs: Args.fromMap({}), + gasPriceTolerance: 1, + }); + + expect(deploy).toBeInstanceOf(Deploy); + expect(deploy.header.chainName).toBe(CHAIN_NAME); + expect(deploy.header.ttl.toMilliseconds()).toBe(DEX_TRANSACTION_TTL_MS); + expect(deploy.session.moduleBytes?.moduleBytes).toEqual(wasmBinary); + }); + + it('exposes the payment amount', () => { + const deploy = createWASMContractDeploy({ + publicKey: PUBLIC_KEY, + chainName: CHAIN_NAME, + paymentMotes: PAYMENT_MOTES, + wasmBinary, + runtimeArgs: Args.fromMap({}), + gasPriceTolerance: 1, + }); + + expect(deploy.payment.moduleBytes?.args.args.get('amount')?.toString()).toBe(PAYMENT_MOTES); + }); + }); +}); + +describe('DexContractRepository builders', () => { + const NETWORK = 'testnet' as const; + + const GRPC_URL = { + mainnet: 'https://rpc.mainnet.example.com', + testnet: 'https://rpc.testnet.example.com', + devnet: '', + integration: '', + }; + + const WASM_BINARY = new Uint8Array([9, 8, 7, 6]); + + const makeDexConfig = ( + overrides: { getProxyWasm?: (() => Promise) | undefined } = {}, + ) => ({ + tradeContractPackageHash: TradeContractPackageHash, + wrappedCsprContractPackageHash: WrappedCsprContractPackageHash, + gasPriceTolerance: 1, + getProxyWasm: jest.fn().mockResolvedValue(WASM_BINARY), + ...overrides, + }); + + const makeToken = (overrides: Partial): IDexTokenWithAmount => ({ + id: 'tokA', + name: 'Token A', + symbol: 'TOKA', + icon: null, + decimals: 9, + packageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + isWhitelisted: true, + isBlacklisted: false, + fiatRates: null, + totalValueLocked: null, + volume24h: null, + amountFormatted: '0', + amountRaw: '0', + ...overrides, + }); + + // The token DTO maps the WCSPR record onto the synthetic `cspr` id while keeping the real + // on-chain package hash, so the native fixture carries the WCSPR hash here too. + const NATIVE = makeToken({ + id: 'cspr', + symbol: 'CSPR', + packageHash: WrappedCsprContractPackageHash[NETWORK], + }); + const TOKEN_A = makeToken({ + id: 'tokA', + packageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + const TOKEN_B = makeToken({ + id: 'tokB', + packageHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }); + + // Amounts are assigned by position (first/second), not by token identity, so a native token + // can occupy either position and carries that position's amount. + const FIRST_AMOUNT_RAW = '1000'; + const SECOND_AMOUNT_RAW = '2000'; + const asFirst = (token: IDexTokenWithAmount): IDexTokenWithAmount => ({ + ...token, + amountRaw: FIRST_AMOUNT_RAW, + }); + const asSecond = (token: IDexTokenWithAmount): IDexTokenWithAmount => ({ + ...token, + amountRaw: SECOND_AMOUNT_RAW, + }); + + const SLIPPAGE = 3; + const DEADLINE_MINUTES = 20; + const AMOUNT_OUT_MIN = '1940'; // calculateMinAmountWithSlippage('2000', 3) + const AMOUNT_IN_MAX = '1030'; // calculateMaxAmountWithSlippage('1000', 3) + const BLOCK_TIME_MS = 1_700_000_000_000; + + /** A repository whose chain-time read is stubbed, so the built deadline is deterministic. */ + const makeRepo = ( + dexConfig: ReturnType = makeDexConfig(), + ): DexContractRepository => { + const repo = new DexContractRepository(GRPC_URL, dexConfig); + jest.spyOn(repo, 'getLatestBlockTime').mockResolvedValue(BLOCK_TIME_MS); + + return repo; + }; + + /** Decodes the byte-array inner args carried inside the proxy envelope's `args` field. */ + const decodeInnerArgs = (outerRuntimeArgs: Args): Args => { + const list = outerRuntimeArgs.args.get('args'); + const bytes = Uint8Array.from( + (list?.list?.elements ?? []).map(el => Number(el.ui8!.toString())), + ); + + return Args.fromBytes(bytes); + }; + + const swapParams = ({ + path, + ...overrides + }: { + firstToken: IDexTokenWithAmount; + secondToken: IDexTokenWithAmount; + quoteType: SwapQuoteType; + useTransactionV1?: boolean; + slippage?: number; + deadline?: number; + path?: string[]; + }) => ({ + network: NETWORK, + publicKey: PUBLIC_KEY, + slippage: SLIPPAGE, + deadline: DEADLINE_MINUTES, + useTransactionV1: true, + // The route the builder accepts has to start at the input token and end at the output + // token; each case therefore derives it from its own pair unless it overrides it. + path: path ?? [overrides.firstToken.packageHash, overrides.secondToken.packageHash], + ...overrides, + }); + + describe('buildSwapTransaction', () => { + it.each([ + { + name: 'CSPR→token ExactIn', + params: swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + entryPoint: 'swap_exact_cspr_for_tokens', + innerKeys: ['path', 'to', 'deadline', 'amount_out_min'], + attachedValue: FIRST_AMOUNT_RAW, + paymentMotes: DEX_PAYMENT_AMOUNT.swapCsprForToken, + }, + { + name: 'CSPR→token ExactOut', + params: swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactOut, + }), + entryPoint: 'swap_cspr_for_exact_tokens', + innerKeys: ['path', 'to', 'deadline', 'amount_out'], + attachedValue: AMOUNT_IN_MAX, + paymentMotes: DEX_PAYMENT_AMOUNT.swapCsprForToken, + }, + { + name: 'token→CSPR ExactIn', + params: swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(NATIVE), + quoteType: SwapQuoteType.ExactIn, + }), + entryPoint: 'swap_exact_tokens_for_cspr', + innerKeys: ['path', 'to', 'deadline', 'amount_in', 'amount_out_min'], + attachedValue: '0', + paymentMotes: DEX_PAYMENT_AMOUNT.swapCsprForToken, + }, + { + name: 'token→CSPR ExactOut', + params: swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(NATIVE), + quoteType: SwapQuoteType.ExactOut, + }), + entryPoint: 'swap_tokens_for_exact_cspr', + innerKeys: ['path', 'to', 'deadline', 'amount_in_max', 'amount_out'], + attachedValue: '0', + paymentMotes: DEX_PAYMENT_AMOUNT.swapCsprForToken, + }, + { + name: 'token→token ExactIn', + params: swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + entryPoint: 'swap_exact_tokens_for_tokens', + innerKeys: ['path', 'to', 'deadline', 'amount_in', 'amount_out_min'], + attachedValue: '0', + paymentMotes: DEX_PAYMENT_AMOUNT.swapTokenForToken, + }, + { + name: 'token→token ExactOut', + params: swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactOut, + }), + entryPoint: 'swap_tokens_for_exact_tokens', + innerKeys: ['path', 'to', 'deadline', 'amount_in_max', 'amount_out'], + attachedValue: '0', + paymentMotes: DEX_PAYMENT_AMOUNT.swapTokenForToken, + }, + ])( + '$name: entryPoint, inner args, proxy envelope and payment', + async ({ params, entryPoint, innerKeys, attachedValue, paymentMotes }) => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = makeRepo(); + + await repo.buildSwapTransaction(params); + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0][0]; + expect(call.wasmBinary).toBe(WASM_BINARY); + expect(call.paymentMotes).toBe(paymentMotes); + + const outerArgs = call.runtimeArgs; + expect(outerArgs.args.get('entry_point')?.stringVal?.toString()).toBe(entryPoint); + expect(outerArgs.args.get('attached_value')?.ui512?.toString()).toBe(attachedValue); + expect(outerArgs.args.get('amount')?.ui512?.toString()).toBe(attachedValue); + + const innerArgs = decodeInnerArgs(outerArgs); + expect([...innerArgs.args.keys()].sort()).toEqual([...innerKeys].sort()); + + // The recipient of the swapped-for tokens, the on-chain expiry and the route are the + // three args whose value nothing else in the suite reads back. + expect(innerArgs.args.get('to')?.key?.toPrefixedString()).toBe( + PublicKey.fromHex(PUBLIC_KEY).accountHash().toPrefixedString(), + ); + expect(innerArgs.args.get('deadline')?.ui64?.toString()).toBe( + String(BLOCK_TIME_MS + DEADLINE_MINUTES * 60_000), + ); + expect( + innerArgs.args.get('path')?.list?.elements.map(el => el.key?.toPrefixedString()), + ).toEqual(params.path.map(hash => `hash-${hash}`)); + + if (innerArgs.args.has('amount_out_min')) { + expect(innerArgs.args.get('amount_out_min')?.ui256?.toString()).toBe(AMOUNT_OUT_MIN); + } + if (innerArgs.args.has('amount_in_max')) { + expect(innerArgs.args.get('amount_in_max')?.ui256?.toString()).toBe(AMOUNT_IN_MAX); + } + if (innerArgs.args.has('amount_in')) { + expect(innerArgs.args.get('amount_in')?.ui256?.toString()).toBe('1000'); + } + if (innerArgs.args.has('amount_out')) { + expect(innerArgs.args.get('amount_out')?.ui256?.toString()).toBe('2000'); + } + }, + ); + + it('both tokens native: rejects with "Invalid swap entry point"', async () => { + const repo = makeRepo(); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(NATIVE), + quoteType: SwapQuoteType.ExactIn, + }), + ), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + message: expect.stringContaining('Invalid swap entry point'), + }); + }); + + it('proxy envelope: outer args are exactly package_hash, entry_point, args, attached_value, amount', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = makeRepo(); + + await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + ); + + const outerArgs = spy.mock.calls[0][0].runtimeArgs; + expect([...outerArgs.args.keys()].sort()).toEqual( + ['package_hash', 'entry_point', 'args', 'attached_value', 'amount'].sort(), + ); + expect(outerArgs.args.get('package_hash')?.byteArray?.toString()).toBe( + TradeContractPackageHash[NETWORK], + ); + }); + + it('supportsTransactionV1=true: returns a real Transaction (transaction set, deploy undefined), kind "swap"', async () => { + const repo = makeRepo(); + jest.spyOn(repo as any, '_getClient').mockReturnValue({ + getStatus: jest.fn().mockResolvedValue({ apiVersion: '2.0.0' }), + }); + + const result = await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + useTransactionV1: true, + }), + ); + + expect(result.kind).toBe('swap'); + expect(result.transaction).toBeInstanceOf(Transaction); + expect(result.deploy).toBeUndefined(); + }); + + it('supportsTransactionV1=false: returns a real Deploy (deploy set, transaction undefined), kind "swap"', async () => { + const repo = makeRepo(); + + const result = await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + useTransactionV1: false, + }), + ); + + expect(result.kind).toBe('swap'); + expect(result.deploy).toBeInstanceOf(Deploy); + expect(result.transaction).toBeUndefined(); + }); + + describe('route endpoints', () => { + const expectRejectedRoute = async (path: string[], expectedMessage: string) => { + const repo = makeRepo(); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + path, + }), + ), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + type: 'buildSwapTransaction', + message: expect.stringContaining(expectedMessage), + }); + }; + + it('rejects a route whose terminal hop is not the selected output token', async () => { + const attackerToken = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + + await expectRejectedRoute([TOKEN_A.packageHash, attackerToken], 'route ends at'); + }); + + it('rejects a route whose first hop is not the selected input token', async () => { + await expectRejectedRoute([TOKEN_B.packageHash, TOKEN_B.packageHash], 'route starts at'); + }); + + it('rejects a route with fewer than two hops', async () => { + await expectRejectedRoute([TOKEN_A.packageHash], 'at least 2 hops'); + }); + + it('accepts a multi-hop route whose ends match the selected pair', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = makeRepo(); + + await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + path: [TOKEN_A.packageHash, NATIVE.packageHash, TOKEN_B.packageHash], + }), + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('matches endpoints case-insensitively', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = makeRepo(); + + await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + path: [TOKEN_A.packageHash.toUpperCase(), TOKEN_B.packageHash.toUpperCase()], + }), + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('resolves a native leg against the configured WCSPR hash', async () => { + const repo = makeRepo(); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + path: [TOKEN_A.packageHash, TOKEN_B.packageHash], + }), + ), + ).rejects.toMatchObject({ + message: expect.stringContaining(WrappedCsprContractPackageHash[NETWORK]), + }); + }); + }); + + describe('slippage and deadline bounds', () => { + const expectRejected = async ( + overrides: { slippage?: number; deadline?: number }, + expectedMessage: string, + ) => { + const repo = makeRepo(); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + ...overrides, + }), + ), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + type: 'buildSwapTransaction', + message: expect.stringContaining(expectedMessage), + }); + }; + + // 100 is what a basis-points/percent confusion on `recommendedSlippageBps` produces for a + // backend-recommended 1%, and it would encode `amount_out_min: 0`. + it.each([100, 100.5, -1, NaN, Infinity])('rejects slippage %p', async slippage => { + await expectRejected({ slippage }, 'Invalid slippage'); + }); + + it('rejects a slippage above MAX_SLIPPAGE', async () => { + await expectRejected({ slippage: MAX_SLIPPAGE + 0.01 }, 'Invalid slippage'); + }); + + it.each([0, -5, NaN, Infinity, MAX_DEADLINE + 1])('rejects deadline %p', async deadline => { + await expectRejected({ deadline }, 'Invalid deadline'); + }); + }); + + it('derives the deadline from chain time, not the device clock', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = makeRepo(); + jest.spyOn(Date, 'now').mockReturnValue(BLOCK_TIME_MS + 60 * 60 * 1000); + + await repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + ); + + const innerArgs = decodeInnerArgs(spy.mock.calls[0][0].runtimeArgs); + expect(innerArgs.args.get('deadline')?.ui64?.toString()).toBe( + String(BLOCK_TIME_MS + DEADLINE_MINUTES * 60_000), + ); + }); + + it('rejects when the chain-time read fails, rather than falling back to the device clock', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + jest + .spyOn(repo, 'getLatestBlockTime') + .mockRejectedValue(new DexError(new Error('rpc down'), 'getLatestBlockTime')); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(TOKEN_A), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + ), + ).rejects.toMatchObject({ name: 'DexRepositoryError', message: 'rpc down' }); + }); + + it('rejects a DexError typed "buildSwapTransaction" naming getProxyWasm when the config omits it', async () => { + const repo = makeRepo(makeDexConfig({ getProxyWasm: undefined })); + + await expect( + repo.buildSwapTransaction( + swapParams({ + firstToken: asFirst(NATIVE), + secondToken: asSecond(TOKEN_B), + quoteType: SwapQuoteType.ExactIn, + }), + ), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + type: 'buildSwapTransaction', + message: expect.stringContaining('getProxyWasm'), + }); + }); + }); + + describe('buildApprovalTransaction', () => { + it('direct contract-package call: kind, entryPoint, args (spender + amount) and payment; no wasm fetched', async () => { + const dexConfig = makeDexConfig(); + const spy = jest + .spyOn(transactionBuilders, 'createContractPackageCallTransaction') + .mockReturnValue({ fake: 'transaction' } as unknown as Transaction); + const repo = new DexContractRepository(GRPC_URL, dexConfig); + + const result = await repo.buildApprovalTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + contractPackageHash: TOKEN_A.packageHash, + amount: '500', + useTransactionV1: true, + }); + + expect(result.kind).toBe('approve'); + expect(result.entryPoint).toBe('approve'); + expect(result.paymentMotes).toBe(DEX_PAYMENT_AMOUNT.approve); + expect(dexConfig.getProxyWasm).not.toHaveBeenCalled(); + + const call = spy.mock.calls[0][0]; + expect(call.contractPackageHash).toBe(TOKEN_A.packageHash); + expect(call.entryPoint).toBe('approve'); + expect(call.runtimeArgs.args.get('spender')?.key?.toPrefixedString()).toBe( + `hash-${TradeContractPackageHash[NETWORK]}`, + ); + expect(call.runtimeArgs.args.get('amount')?.ui256?.toString()).toBe('500'); + }); + + it('useTransactionV1=true: returns a real Transaction (transaction set, deploy undefined)', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildApprovalTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + contractPackageHash: TOKEN_A.packageHash, + amount: '500', + useTransactionV1: true, + }); + + expect(result.transaction).toBeInstanceOf(Transaction); + expect(result.deploy).toBeUndefined(); + }); + + it('useTransactionV1=false: returns a real Deploy (deploy set, transaction undefined)', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildApprovalTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + contractPackageHash: TOKEN_A.packageHash, + amount: '500', + useTransactionV1: false, + }); + + expect(result.deploy).toBeInstanceOf(Deploy); + expect(result.transaction).toBeUndefined(); + }); + }); + + describe('buildWrapTransaction', () => { + it('deposit: kind, entryPoint, WCSPR proxy target, empty inner args, attached_value=amount=motesAmount, payment', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildWrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + motesAmount: '22000000000', + useTransactionV1: true, + }); + + expect(result.kind).toBe('wrap'); + expect(result.entryPoint).toBe('deposit'); + expect(result.paymentMotes).toBe(DEX_PAYMENT_AMOUNT.wrap); + + const call = spy.mock.calls[0][0]; + const outerArgs = call.runtimeArgs; + expect(outerArgs.args.get('package_hash')?.byteArray?.toString()).toBe( + WrappedCsprContractPackageHash[NETWORK], + ); + expect(outerArgs.args.get('attached_value')?.ui512?.toString()).toBe('22000000000'); + expect(outerArgs.args.get('amount')?.ui512?.toString()).toBe('22000000000'); + + const innerArgs = decodeInnerArgs(outerArgs); + expect([...innerArgs.args.keys()]).toEqual([]); + }); + + it('supportsTransactionV1=true: returns a real Transaction, kind "wrap"', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + jest.spyOn(repo as any, '_getClient').mockReturnValue({ + getStatus: jest.fn().mockResolvedValue({ apiVersion: '2.0.0' }), + }); + + const result = await repo.buildWrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + motesAmount: '22000000000', + useTransactionV1: true, + }); + + expect(result.transaction).toBeInstanceOf(Transaction); + expect(result.deploy).toBeUndefined(); + }); + + it('supportsTransactionV1=false: returns a real Deploy, kind "wrap"', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildWrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + motesAmount: '22000000000', + useTransactionV1: false, + }); + + expect(result.deploy).toBeInstanceOf(Deploy); + expect(result.transaction).toBeUndefined(); + }); + + it('rejects a DexError typed "buildWrapTransaction" naming getProxyWasm when the config omits it', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig({ getProxyWasm: undefined })); + + await expect( + repo.buildWrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + motesAmount: '22000000000', + useTransactionV1: true, + }), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + type: 'buildWrapTransaction', + message: expect.stringContaining('getProxyWasm'), + }); + }); + }); + + describe('buildUnwrapTransaction', () => { + it('withdraw: kind, entryPoint, inner args = { amount }, attached_value=amount=0, payment', async () => { + const spy = jest + .spyOn(transactionBuilders, 'createSessionWasmTransaction') + .mockResolvedValue({ fake: 'transaction' } as unknown as Transaction); + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildUnwrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + rawAmount: '1000', + useTransactionV1: true, + }); + + expect(result.kind).toBe('unwrap'); + expect(result.entryPoint).toBe('withdraw'); + expect(result.paymentMotes).toBe(DEX_PAYMENT_AMOUNT.unwrap); + + const call = spy.mock.calls[0][0]; + const outerArgs = call.runtimeArgs; + expect(outerArgs.args.get('attached_value')?.ui512?.toString()).toBe('0'); + expect(outerArgs.args.get('amount')?.ui512?.toString()).toBe('0'); + + const innerArgs = decodeInnerArgs(outerArgs); + expect([...innerArgs.args.keys()]).toEqual(['amount']); + expect(innerArgs.args.get('amount')?.ui256?.toString()).toBe('1000'); + }); + + it('supportsTransactionV1=true: returns a real Transaction, kind "unwrap"', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + jest.spyOn(repo as any, '_getClient').mockReturnValue({ + getStatus: jest.fn().mockResolvedValue({ apiVersion: '2.0.0' }), + }); + + const result = await repo.buildUnwrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + rawAmount: '1000', + useTransactionV1: true, + }); + + expect(result.transaction).toBeInstanceOf(Transaction); + expect(result.deploy).toBeUndefined(); + }); + + it('supportsTransactionV1=false: returns a real Deploy, kind "unwrap"', async () => { + const repo = new DexContractRepository(GRPC_URL, makeDexConfig()); + + const result = await repo.buildUnwrapTransaction({ + network: NETWORK, + publicKey: PUBLIC_KEY, + rawAmount: '1000', + useTransactionV1: false, + }); + + expect(result.deploy).toBeInstanceOf(Deploy); + expect(result.transaction).toBeUndefined(); + }); + }); +}); diff --git a/src/data/repositories/dex/dex.test.ts b/src/data/repositories/dex/dex.test.ts new file mode 100644 index 0000000..6078dd3 --- /dev/null +++ b/src/data/repositories/dex/dex.test.ts @@ -0,0 +1,462 @@ +import { DexContractRepository } from './index'; +import { + DexError, + TradeContractPackageHash, + WrappedCsprContractPackageHash, +} from '../../../domain'; + +const mockSetReferrer = jest.fn(); +const mockSetCustomHeaders = jest.fn(); +const mockHttpHandlerCtor = jest.fn(); + +jest.mock('casper-js-sdk', () => ({ + ...jest.requireActual('casper-js-sdk'), + HttpHandler: class { + constructor(...args: unknown[]) { + mockHttpHandlerCtor(...args); + } + setReferrer = mockSetReferrer; + setCustomHeaders = mockSetCustomHeaders; + }, +})); + +const PUBLIC_KEY = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; +/** blake2b-256(accountKey.bytes() ++ tradeContractKey.bytes()) for PUBLIC_KEY on mainnet. */ +const ALLOWANCES_DICT_KEY = 'd3cf5c22d374ac6ec3e20c825ad6f38b47f15ba4db675ab3ab598d0fa6c03782'; + +const GRPC_URL = { + mainnet: 'https://rpc.mainnet.example.com', + testnet: 'https://rpc.testnet.example.com', + devnet: '', + integration: '', +}; + +const DEX_CONFIG = { + tradeContractPackageHash: TradeContractPackageHash, + wrappedCsprContractPackageHash: WrappedCsprContractPackageHash, + gasPriceTolerance: 1, +}; + +/** The shape the sdk throws for an RPC error: the code rides on the wrapped `sourceErr`. */ +const rpcError = (code: number) => + Object.assign(new Error(`rpc ${code}`), { statusCode: code, sourceErr: { code } }); + +/** Fake RpcClient — only the methods a given test exercises need to be present. */ +const makeClient = (overrides: Record = {}) => overrides; + +/** Stubs the private `_getClient` factory so tests can inject a fake RpcClient. */ +const stubClient = (repo: DexContractRepository, client: Record) => + jest.spyOn(repo as any, '_getClient').mockReturnValue(client); + +const makeVersion = (contractVersion: number, contractHashHex: string) => ({ + contractVersion, + contractHash: { hash: { toHex: () => `contract-${contractHashHex}` } }, +}); + +const makeQueryLatestGlobalState = (...versions: ReturnType[] | [string]) => + jest.fn().mockResolvedValue({ + storedValue: { + contractPackage: { + versions: + typeof versions[0] === 'string' + ? [makeVersion(1, versions[0])] + : (versions as ReturnType[]), + }, + }, + }); + +describe('DexContractRepository', () => { + describe('getAllowance', () => { + it('reads the allowances dictionary with a key derived from both keys (keysToHex)', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + const getDictionaryItemByIdentifier = jest + .fn() + .mockResolvedValue({ storedValue: { clValue: { toString: () => '999' } } }); + stubClient( + repo, + makeClient({ + queryLatestGlobalState: makeQueryLatestGlobalState('def456'), + getDictionaryItemByIdentifier, + }), + ); + + const result = await repo.getAllowance({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + }); + + expect(result).toBe('999'); + const identifier = getDictionaryItemByIdentifier.mock.calls[0][1]; + expect(identifier.contractNamedKey.dictionaryName).toBe('allowances'); + + // Fixed vector rather than a re-run of `keysToHex`; the derivation itself is pinned in + // src/utils/casperSdk/dex-contract.test.ts. + expect(identifier.contractNamedKey.dictionaryItemKey).toBe(ALLOWANCES_DICT_KEY); + }); + + it('rejects a DexError typed "getAllowance" on RPC failure', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + stubClient( + repo, + makeClient({ queryLatestGlobalState: jest.fn().mockRejectedValue(new Error('rpc down')) }), + ); + + await expect( + repo.getAllowance({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + }), + ).rejects.toMatchObject({ name: 'DexRepositoryError', type: 'getAllowance' }); + }); + + it('resolves "" when the allowances dictionary has no entry for the spender', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + stubClient( + repo, + makeClient({ + queryLatestGlobalState: makeQueryLatestGlobalState('def456'), + // ErrorCode.QueryFailed — the node answered and the item is not in state. + getDictionaryItemByIdentifier: jest.fn().mockRejectedValue(rpcError(-32003)), + }), + ); + + await expect( + repo.getAllowance({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + }), + ).resolves.toBe(''); + }); + + it('rejects rather than reading "" when the dictionary lookup itself fails', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + stubClient( + repo, + makeClient({ + queryLatestGlobalState: makeQueryLatestGlobalState('def456'), + getDictionaryItemByIdentifier: jest.fn().mockRejectedValue(new Error('socket hang up')), + }), + ); + + await expect( + repo.getAllowance({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + }), + ).rejects.toMatchObject({ name: 'DexRepositoryError', type: 'getAllowance' }); + }); + + it('reads a transient allowance failure as "approval required", and logs it', async () => { + const log = { + reportError: jest.fn(), + log: jest.fn(), + logGroup: jest.fn(), + logGroupEnd: jest.fn(), + }; + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG, undefined, {}, log); + stubClient( + repo, + makeClient({ + queryLatestGlobalState: makeQueryLatestGlobalState('def456'), + getDictionaryItemByIdentifier: jest.fn().mockRejectedValue(new Error('socket hang up')), + }), + ); + + await expect( + repo.checkApprovalRequired({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + requiredAmount: '1', + }), + ).resolves.toBe(true); + expect(log.reportError).toHaveBeenCalled(); + }); + }); + + describe('proxy WASM integrity', () => { + const WASM = new Uint8Array([0x00, 0x61, 0x73, 0x6d]); + // sha256 of those four bytes. + const WASM_SHA256 = 'cd5d4935a48c0672cb06407bb443bc0087aff947c6b864bac886982c73b3027f'; + + const configWith = (expectedProxyWasmSha256?: string) => ({ + ...DEX_CONFIG, + expectedProxyWasmSha256, + getProxyWasm: jest.fn(async () => WASM), + }); + + it('refuses to build when the loaded bytes do not match the expected hash', async () => { + const repo = new DexContractRepository(GRPC_URL, configWith('00'.repeat(32))); + + await expect( + repo.buildWrapTransaction({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + motesAmount: '1000000000', + useTransactionV1: true, + }), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + message: expect.stringContaining('does not match the expected sha256'), + }); + }); + + it('verifies once and reuses the bytes across builds', async () => { + const dexConfig = configWith(WASM_SHA256); + const repo = new DexContractRepository(GRPC_URL, dexConfig); + const build = () => + repo.buildWrapTransaction({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + motesAmount: '1000000000', + useTransactionV1: false, + }); + + await expect(build()).resolves.toMatchObject({ kind: 'wrap' }); + await expect(build()).resolves.toMatchObject({ kind: 'wrap' }); + expect(dexConfig.getProxyWasm).toHaveBeenCalledTimes(1); + }); + + it('uses the bytes as supplied when no expected hash is configured', async () => { + const repo = new DexContractRepository(GRPC_URL, configWith()); + + await expect( + repo.buildWrapTransaction({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + motesAmount: '1000000000', + useTransactionV1: false, + }), + ).resolves.toMatchObject({ kind: 'wrap' }); + }); + }); + + describe('revoking an approval', () => { + it('builds an approve of 0 to the trade contract', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + const spy = jest.spyOn(repo, 'buildApprovalTransaction'); + + await repo.buildRevokeApprovalTransaction({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + contractPackageHash: 'ab'.repeat(32), + useTransactionV1: true, + }); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ amount: '0' })); + }); + }); + + describe('unconfigured networks', () => { + const UNCONFIGURED = { + tradeContractPackageHash: { ...TradeContractPackageHash, devnet: '' }, + wrappedCsprContractPackageHash: { ...WrappedCsprContractPackageHash, devnet: '' }, + gasPriceTolerance: 1, + getProxyWasm: async () => new Uint8Array([1]), + }; + + it('refuses to build an approval against an empty trade contract package hash', async () => { + const repo = new DexContractRepository(GRPC_URL, UNCONFIGURED); + + await expect( + repo.buildApprovalTransaction({ + network: 'devnet', + publicKey: PUBLIC_KEY, + contractPackageHash: 'cph', + amount: '1', + useTransactionV1: true, + }), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + message: expect.stringContaining('No trade contract package hash configured'), + }); + }); + + it('refuses to build a wrap against an empty wrapped-CSPR contract package hash', async () => { + const repo = new DexContractRepository(GRPC_URL, UNCONFIGURED); + + await expect( + repo.buildWrapTransaction({ + network: 'devnet', + publicKey: PUBLIC_KEY, + motesAmount: '1000000000', + useTransactionV1: true, + }), + ).rejects.toMatchObject({ + name: 'DexRepositoryError', + message: expect.stringContaining('No wrapped-CSPR contract package hash configured'), + }); + }); + }); + + describe('contract version selection', () => { + /** Reads back the contract hash the dictionary lookup was pointed at. */ + const dictionaryTargetOf = async ( + queryLatestGlobalState: jest.Mock, + ): Promise => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + const getDictionaryItemByIdentifier = jest + .fn() + .mockResolvedValue({ storedValue: { clValue: { toString: () => '1' } } }); + stubClient(repo, makeClient({ queryLatestGlobalState, getDictionaryItemByIdentifier })); + + await repo.getAllowance({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + }); + + return getDictionaryItemByIdentifier.mock.calls[0][1].contractNamedKey.key as string; + }; + + // A stale version's `allowances` dictionary reports an allowance the user never granted, or + // misses one they did — either way they pay for an approval on every swap. + it('reads the allowance from the highest contract version', async () => { + await expect( + dictionaryTargetOf( + makeQueryLatestGlobalState( + makeVersion(1, 'old'), + makeVersion(3, 'newest'), + makeVersion(2, 'mid'), + ), + ), + ).resolves.toContain('newest'); + }); + + it('does not depend on the versions arriving in order', async () => { + await expect( + dictionaryTargetOf( + makeQueryLatestGlobalState(makeVersion(3, 'newest'), makeVersion(1, 'old')), + ), + ).resolves.toContain('newest'); + }); + }); + + describe('checkApprovalRequired', () => { + it('resolves false for WCSPR without any RPC call', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + const getClientSpy = jest.spyOn(repo as any, '_getClient'); + + const result = await repo.checkApprovalRequired({ + network: 'mainnet', + contractPackageHash: WrappedCsprContractPackageHash.mainnet, + publicKey: PUBLIC_KEY, + requiredAmount: '100', + }); + + expect(result).toBe(false); + expect(getClientSpy).not.toHaveBeenCalled(); + }); + + it('resolves true when the allowance is less than the required amount', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + jest.spyOn(repo, 'getAllowance').mockResolvedValue('100'); + + await expect( + repo.checkApprovalRequired({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + requiredAmount: '200', + }), + ).resolves.toBe(true); + }); + + it('resolves false when the allowance equals the required amount', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + jest.spyOn(repo, 'getAllowance').mockResolvedValue('200'); + + await expect( + repo.checkApprovalRequired({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + requiredAmount: '200', + }), + ).resolves.toBe(false); + }); + + it('resolves true (assumes approval required) when the allowance read rejects', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + jest.spyOn(repo, 'getAllowance').mockRejectedValue(new Error('rpc down')); + + await expect( + repo.checkApprovalRequired({ + network: 'mainnet', + contractPackageHash: 'cph', + publicKey: PUBLIC_KEY, + requiredAmount: '200', + }), + ).resolves.toBe(true); + }); + }); + + describe('getLatestBlockTime', () => { + it('resolves the latest block timestamp in ms', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + stubClient( + repo, + makeClient({ + getLatestBlock: jest + .fn() + .mockResolvedValue({ block: { timestamp: { toMilliseconds: () => 1700000000000 } } }), + }), + ); + + await expect(repo.getLatestBlockTime({ network: 'mainnet' })).resolves.toBe(1700000000000); + }); + + it('rejects a DexError typed "getLatestBlockTime" on RPC failure', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + stubClient( + repo, + makeClient({ getLatestBlock: jest.fn().mockRejectedValue(new Error('rpc down')) }), + ); + + await expect(repo.getLatestBlockTime({ network: 'mainnet' })).rejects.toMatchObject({ + name: 'DexRepositoryError', + type: 'getLatestBlockTime', + }); + }); + }); + + describe('error wrapping', () => { + it('rethrows an inner DexError as-is, without re-wrapping its type', async () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + const inner = new DexError(new Error('inner failure'), 'getAllowance'); + stubClient(repo, makeClient({ getLatestBlock: jest.fn().mockRejectedValue(inner) })); + + await expect(repo.getLatestBlockTime({ network: 'mainnet' })).rejects.toBe(inner); + }); + }); + + describe('_getClient', () => { + beforeEach(() => jest.clearAllMocks()); + + it("default rpc options: 'fetch' handler + setReferrer, no Referer header", () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG); + (repo as any)._getClient('mainnet'); + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(GRPC_URL.mainnet, 'fetch'); + expect(mockSetReferrer).toHaveBeenCalledWith('https://casperwallet.io'); + expect(mockSetCustomHeaders).not.toHaveBeenCalled(); + }); + + it("mobile rpc options: 'axios' handler + literal Referer header (+auth)", () => { + const repo = new DexContractRepository(GRPC_URL, DEX_CONFIG, 'token', { + handlerType: 'axios', + referrerMode: 'referer-header', + }); + (repo as any)._getClient('mainnet'); + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(GRPC_URL.mainnet, 'axios'); + expect(mockSetCustomHeaders).toHaveBeenCalledWith({ + Referer: 'https://casperwallet.io', + Authorization: 'token', + }); + expect(mockSetReferrer).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/data/repositories/dex/index.ts b/src/data/repositories/dex/index.ts new file mode 100644 index 0000000..5f8e708 --- /dev/null +++ b/src/data/repositories/dex/index.ts @@ -0,0 +1,593 @@ +import { + CasperNetwork, + CasperSdkNetworkName, + CSPR_NATIVE_TOKEN_ID, + DEX_PAYMENT_AMOUNT, + DexError, + DexErrorType, + IBuildApprovalParams, + IBuildRevokeApprovalParams, + IBuildSwapParams, + IBuildUnwrapParams, + IBuildWrapParams, + IBuiltDexTransaction, + ICasperRpcOptions, + IDexConfig, + IDexContractRepository, + IDexTokenWithAmount, + ILogger, + isDexError, + MAX_DEADLINE, + MAX_SLIPPAGE, + MIN_DEADLINE, + SwapQuoteType, +} from '../../../domain'; +import { Args, CLTypeKey, CLTypeUInt8, CLValue, Key, PublicKey, RpcClient } from 'casper-js-sdk'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import Decimal from 'decimal.js'; +import { + getContractHash, + getDictionaryValue, + keysToHex, +} from '../../../utils/casperSdk/dex-contract'; +import { createCasperRpcClient } from '../../../utils/casperSdk/rpcClient'; +import { + calculateMaxAmountWithSlippage, + calculateMinAmountWithSlippage, + isKeysEqual, +} from '../../../utils'; +import { + createContractDeploy, + createContractPackageCallTransaction, + createSessionWasmTransaction, + createWASMContractDeploy, +} from './transactionBuilders'; + +/** `IDexConfig` with the setup factory's defaults applied and the setup-wide wrapped-CSPR hash. */ +export interface IResolvedDexConfig { + tradeContractPackageHash: Record; + wrappedCsprContractPackageHash: Record; + gasPriceTolerance: number; + expectedProxyWasmSha256?: string; + // Optional here, required on `IDexConfig`: the runtime guard still has to hold for JavaScript + // callers. + getProxyWasm?: IDexConfig['getProxyWasm']; +} + +export class DexContractRepository implements IDexContractRepository { + constructor( + private _grpcUrl: Record, + private _dexConfig: IResolvedDexConfig, + private _httpAuthorizationHeader?: string, + private _rpcOptions: ICasperRpcOptions = {}, + private _log?: ILogger, + ) {} + + private _verifiedProxyWasm?: Uint8Array; + + async getAllowance(params: { + network: CasperNetwork; + contractPackageHash: string; + publicKey: string; + operatorContractPackageHash?: string; + }): Promise { + const { network, contractPackageHash, publicKey, operatorContractPackageHash } = params; + + try { + const client = this._getClient(network); + const { contractHash } = await getContractHash(contractPackageHash, client); + + const key = CLValue.newCLKey( + Key.newKey(PublicKey.fromHex(publicKey).accountHash().toPrefixedString()), + ); + const operator = CLValue.newCLKey( + Key.newKey(operatorContractPackageHash ?? this._tradeContractPackageHash(network)), + ); + + const dictKey = keysToHex(key, operator); + + const allowanceResult = await getDictionaryValue(client, contractHash, 'allowances', dictKey); + + // `''` means the dictionary has no entry for this spender; a failed read throws instead. + return allowanceResult?.toString() ?? ''; + } catch (e) { + this._processError(e, 'getAllowance'); + } + } + + async checkApprovalRequired(params: { + network: CasperNetwork; + contractPackageHash: string; + publicKey: string; + requiredAmount: string; + }): Promise { + const { network, contractPackageHash, publicKey, requiredAmount } = params; + + // CSPR (wrapped as WCSPR on-chain) doesn't require approval. + if (contractPackageHash === this._wrappedCsprContractPackageHash(network)) { + return false; + } + + try { + const allowance = await this.getAllowance({ + network, + contractPackageHash, + publicKey, + operatorContractPackageHash: this._tradeContractPackageHash(network), + }); + + return new Decimal(allowance || '0').lt(new Decimal(requiredAmount || '0')); + } catch (e) { + // Fail-safe: an unreadable allowance reads as "approval required", never "already approved". + this._log?.reportError(e, 'DexContractRepository.checkApprovalRequired: allowance read'); + + return true; + } + } + + async getLatestBlockTime(params: { network: CasperNetwork }): Promise { + try { + const client = this._getClient(params.network); + const result = await client.getLatestBlock(); + + return result.block.timestamp.toMilliseconds(); + } catch (e) { + this._processError(e, 'getLatestBlockTime'); + } + } + + /** See {@link IDexContractRepository.buildRevokeApprovalTransaction}. */ + async buildRevokeApprovalTransaction( + params: IBuildRevokeApprovalParams, + ): Promise { + return this.buildApprovalTransaction({ ...params, amount: '0' }); + } + + /** Direct contract-package call, no WASM proxy. Returns an unsigned transaction or deploy. */ + async buildApprovalTransaction(params: IBuildApprovalParams): Promise { + const { network, publicKey, contractPackageHash, amount, useTransactionV1 } = params; + + try { + const entryPoint = 'approve'; + const paymentMotes = DEX_PAYMENT_AMOUNT.approve; + const chainName = CasperSdkNetworkName[network]; + + const runtimeArgs = Args.fromMap({ + spender: CLValue.newCLKey(Key.newKey(this._tradeContractPackageHash(network))), + amount: CLValue.newCLUInt256(amount), + }); + + if (useTransactionV1) { + const transaction = createContractPackageCallTransaction({ + publicKey, + chainName, + entryPoint, + paymentMotes, + runtimeArgs, + contractPackageHash, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + }); + + return { kind: 'approve', entryPoint, paymentMotes, transaction }; + } + + const deploy = createContractDeploy({ + publicKey, + chainName, + paymentMotes, + entryPoint, + runtimeArgs, + contractPackageHash, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + }); + + return { kind: 'approve', entryPoint, paymentMotes, deploy }; + } catch (e) { + this._processError(e, 'buildApprovalTransaction'); + } + } + + /** + * WASM-proxy call. The entry-point names and the inner-arg names/order below are what the + * deployed trade contract expects — changing either breaks the on-chain call. + */ + async buildSwapTransaction(params: IBuildSwapParams): Promise { + const { + network, + publicKey, + firstToken, + secondToken, + path, + quoteType, + slippage, + deadline, + useTransactionV1, + } = params; + + try { + this._assertSlippage(slippage); + this._assertDeadline(deadline); + this._assertRouteEndpoints({ network, path, firstToken, secondToken }); + + const firstTokenAmountRaw = firstToken.amountRaw; + const secondTokenAmountRaw = secondToken.amountRaw; + + const firstTokenAmountRawMax = calculateMaxAmountWithSlippage(firstTokenAmountRaw, slippage); + const secondTokenAmountRawMin = calculateMinAmountWithSlippage( + secondTokenAmountRaw, + slippage, + ); + + const isFirstTokenNative = firstToken.id === CSPR_NATIVE_TOKEN_ID; + const isSecondTokenNative = secondToken.id === CSPR_NATIVE_TOKEN_ID; + const isBothTokensNotNative = !isFirstTokenNative && !isSecondTokenNative; + + // A CSPR-for-CSPR swap is invalid, and has to be rejected before the chain below — + // `isFirstTokenNative` would otherwise claim it and the final fallback is unreachable. + let entryPoint: string; + if (isFirstTokenNative && isSecondTokenNative) { + throw new Error('Invalid swap entry point'); + } else if (isFirstTokenNative) { + entryPoint = + quoteType === SwapQuoteType.ExactIn + ? 'swap_exact_cspr_for_tokens' + : 'swap_cspr_for_exact_tokens'; + } else if (isSecondTokenNative) { + entryPoint = + quoteType === SwapQuoteType.ExactIn + ? 'swap_exact_tokens_for_cspr' + : 'swap_tokens_for_exact_cspr'; + } else if (isBothTokensNotNative) { + entryPoint = + quoteType === SwapQuoteType.ExactIn + ? 'swap_exact_tokens_for_tokens' + : 'swap_tokens_for_exact_tokens'; + } else { + throw new Error('Invalid swap entry point'); + } + + // Block time, not device time: the contract compares the deadline against the chain's + // clock, so a drifted device clock would otherwise shorten or silently extend it. + const blockTime = await this.getLatestBlockTime({ network }); + const deadlineArg = blockTime + 1000 * 60 * deadline; + const account = PublicKey.fromHex(publicKey).accountHash().toPrefixedString(); + + const amountArg = isFirstTokenNative + ? quoteType === SwapQuoteType.ExactIn + ? firstTokenAmountRaw + : firstTokenAmountRawMax + : '0'; + + const rawArgsBytes = Args.fromMap({ + path: CLValue.newCLList( + CLTypeKey, + path.map(item => CLValue.newCLKey(Key.newKey(item))), + ), + to: CLValue.newCLKey(Key.newKey(account)), + deadline: CLValue.newCLUint64(deadlineArg), + + ...(isFirstTokenNative && quoteType === SwapQuoteType.ExactIn + ? { amount_out_min: CLValue.newCLUInt256(secondTokenAmountRawMin) } + : {}), + + ...(isFirstTokenNative && quoteType === SwapQuoteType.ExactOut + ? { amount_out: CLValue.newCLUInt256(secondTokenAmountRaw) } + : {}), + + ...((isSecondTokenNative || isBothTokensNotNative) && quoteType === SwapQuoteType.ExactIn + ? { + amount_in: CLValue.newCLUInt256(firstTokenAmountRaw), + amount_out_min: CLValue.newCLUInt256(secondTokenAmountRawMin), + } + : {}), + + ...((isSecondTokenNative || isBothTokensNotNative) && quoteType === SwapQuoteType.ExactOut + ? { + amount_in_max: CLValue.newCLUInt256(firstTokenAmountRawMax), + amount_out: CLValue.newCLUInt256(secondTokenAmountRaw), + } + : {}), + }).toBytes(); + + const argsBytes = Array.from(rawArgsBytes, byte => CLValue.newCLUint8(byte)); + + const wasmBinary = await this._loadProxyWasm(); + + const runtimeArgs = Args.fromMap({ + package_hash: CLValue.newCLByteArray( + hexToBytes(this._tradeContractPackageHash(network).replace('hash-', '')), + ), + entry_point: CLValue.newCLString(entryPoint), + args: CLValue.newCLList(CLTypeUInt8, argsBytes), + attached_value: CLValue.newCLUInt512(amountArg), + amount: CLValue.newCLUInt512(amountArg), + }); + + const paymentMotes = isBothTokensNotNative + ? DEX_PAYMENT_AMOUNT.swapTokenForToken + : DEX_PAYMENT_AMOUNT.swapCsprForToken; + const chainName = CasperSdkNetworkName[network]; + + if (useTransactionV1) { + const transaction = await createSessionWasmTransaction({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + rpcClient: this._getClient(network), + }); + + return { kind: 'swap', entryPoint, paymentMotes, transaction }; + } + + const deploy = createWASMContractDeploy({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + }); + + return { kind: 'swap', entryPoint, paymentMotes, deploy }; + } catch (e) { + this._processError(e, 'buildSwapTransaction'); + } + } + + /** + * WASM-proxy call to WCSPR's `deposit`. The entry point takes no named args — the amount + * minted is whatever CSPR the proxy attaches. + */ + async buildWrapTransaction(params: IBuildWrapParams): Promise { + const { network, publicKey, motesAmount, useTransactionV1 } = params; + + try { + const entryPoint = 'deposit'; + + const rawArgsBytes = Args.fromMap({}).toBytes(); + const argsBytes = Array.from(rawArgsBytes, byte => CLValue.newCLUint8(byte)); + + const wasmBinary = await this._loadProxyWasm(); + + const runtimeArgs = Args.fromMap({ + package_hash: CLValue.newCLByteArray( + hexToBytes(this._wrappedCsprContractPackageHash(network).replace('hash-', '')), + ), + entry_point: CLValue.newCLString(entryPoint), + args: CLValue.newCLList(CLTypeUInt8, argsBytes), + attached_value: CLValue.newCLUInt512(motesAmount), + amount: CLValue.newCLUInt512(motesAmount), + }); + + const paymentMotes = DEX_PAYMENT_AMOUNT.wrap; + const chainName = CasperSdkNetworkName[network]; + + if (useTransactionV1) { + const transaction = await createSessionWasmTransaction({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + rpcClient: this._getClient(network), + }); + + return { kind: 'wrap', entryPoint, paymentMotes, transaction }; + } + + const deploy = createWASMContractDeploy({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + }); + + return { kind: 'wrap', entryPoint, paymentMotes, deploy }; + } catch (e) { + this._processError(e, 'buildWrapTransaction'); + } + } + + /** + * WASM-proxy call to WCSPR's `withdraw`. Burns the caller's own WCSPR, so nothing is + * attached to the proxy call. + */ + async buildUnwrapTransaction(params: IBuildUnwrapParams): Promise { + const { network, publicKey, rawAmount, useTransactionV1 } = params; + + try { + const entryPoint = 'withdraw'; + + const rawArgsBytes = Args.fromMap({ + amount: CLValue.newCLUInt256(rawAmount), + }).toBytes(); + const argsBytes = Array.from(rawArgsBytes, byte => CLValue.newCLUint8(byte)); + + const wasmBinary = await this._loadProxyWasm(); + + const runtimeArgs = Args.fromMap({ + package_hash: CLValue.newCLByteArray( + hexToBytes(this._wrappedCsprContractPackageHash(network).replace('hash-', '')), + ), + entry_point: CLValue.newCLString(entryPoint), + args: CLValue.newCLList(CLTypeUInt8, argsBytes), + attached_value: CLValue.newCLUInt512(0), + amount: CLValue.newCLUInt512(0), + }); + + const paymentMotes = DEX_PAYMENT_AMOUNT.unwrap; + const chainName = CasperSdkNetworkName[network]; + + if (useTransactionV1) { + const transaction = await createSessionWasmTransaction({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + rpcClient: this._getClient(network), + }); + + return { kind: 'unwrap', entryPoint, paymentMotes, transaction }; + } + + const deploy = createWASMContractDeploy({ + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance: this._dexConfig.gasPriceTolerance, + }); + + return { kind: 'unwrap', entryPoint, paymentMotes, deploy }; + } catch (e) { + this._processError(e, 'buildUnwrapTransaction'); + } + } + + /** + * The slippage bound is the user's only defence against a sandwich attack, and at `100` it + * degenerates to `amount_out_min: 0`. Enforced here rather than left to `clampSlippageValue`, + * because this is the chokepoint every consumer's payload passes through. + */ + private _assertSlippage(slippage: number): void { + if (!Number.isFinite(slippage) || slippage < 0 || slippage > MAX_SLIPPAGE) { + throw new Error( + `Invalid slippage "${slippage}": expected a percent in [0, ${MAX_SLIPPAGE}]. Note that the quote's \`recommendedSlippageBps\` is in basis points, not percent.`, + ); + } + } + + private _assertDeadline(deadline: number): void { + if (!Number.isFinite(deadline) || deadline < MIN_DEADLINE || deadline > MAX_DEADLINE) { + throw new Error( + `Invalid deadline "${deadline}": expected minutes in [${MIN_DEADLINE}, ${MAX_DEADLINE}]`, + ); + } + } + + /** + * The route comes from the trade API and is encoded verbatim into the signed payload, while + * the UI renders the locally-selected tokens. `amount_out_min` bounds the quantity delivered + * but not its identity, so a substituted terminal hop would pay the user in a token they + * never chose. Pin both ends to the tokens the user actually selected. + */ + private _assertRouteEndpoints(params: { + network: CasperNetwork; + path: string[]; + firstToken: IDexTokenWithAmount; + secondToken: IDexTokenWithAmount; + }): void { + const { network, path, firstToken, secondToken } = params; + + if (path.length < 2) { + throw new Error(`Invalid swap route: expected at least 2 hops, got ${path.length}`); + } + + const wrappedCspr = this._wrappedCsprContractPackageHash(network); + const onChainHash = (token: IDexTokenWithAmount): string => + token.id === CSPR_NATIVE_TOKEN_ID ? wrappedCspr : token.packageHash; + + const expectedIn = onChainHash(firstToken); + const expectedOut = onChainHash(secondToken); + + if (!isKeysEqual(path[0], expectedIn)) { + throw new Error( + `Invalid swap route: route starts at "${path[0]}", expected the selected input token "${expectedIn}"`, + ); + } + + if (!isKeysEqual(path[path.length - 1], expectedOut)) { + throw new Error( + `Invalid swap route: route ends at "${path[path.length - 1]}", expected the selected output token "${expectedOut}"`, + ); + } + } + + private _getClient(network: CasperNetwork): RpcClient { + return createCasperRpcClient(this._grpcUrl[network], { + ...this._rpcOptions, + ...(this._httpAuthorizationHeader + ? { authorizationHeader: this._httpAuthorizationHeader } + : {}), + }); + } + + /** Loaded once, then verified against `expectedProxyWasmSha256` when one is configured. */ + private async _loadProxyWasm(): Promise { + if (!this._dexConfig.getProxyWasm) { + throw new Error( + 'dexConfig.getProxyWasm is required to build swap, wrap and unwrap transactions (proxy_caller.wasm bytes)', + ); + } + + if (this._verifiedProxyWasm) { + return this._verifiedProxyWasm; + } + + const wasmBinary = await this._dexConfig.getProxyWasm(); + const { expectedProxyWasmSha256 } = this._dexConfig; + + if (expectedProxyWasmSha256) { + const actual = bytesToHex(sha256(wasmBinary)); + + if (actual.toLowerCase() !== expectedProxyWasmSha256.toLowerCase().replace(/^0x/, '')) { + throw new Error( + `proxy_caller.wasm does not match the expected sha256: got "${actual}", expected "${expectedProxyWasmSha256}"`, + ); + } + } + + this._verifiedProxyWasm = wasmBinary; + + return wasmBinary; + } + + /** An empty hash would build a zero-length address that is signed, submitted and then reverts. */ + private _requireContractPackageHash( + hash: string | undefined, + kind: 'trade' | 'wrapped-CSPR', + network: CasperNetwork, + ): string { + if (!hash) { + throw new Error( + `No ${kind} contract package hash configured for "${network}". Supply one through \`dexConfig\`.`, + ); + } + + return hash; + } + + private _tradeContractPackageHash(network: CasperNetwork): string { + return this._requireContractPackageHash( + this._dexConfig.tradeContractPackageHash[network], + 'trade', + network, + ); + } + + private _wrappedCsprContractPackageHash(network: CasperNetwork): string { + return this._requireContractPackageHash( + this._dexConfig.wrappedCsprContractPackageHash[network], + 'wrapped-CSPR', + network, + ); + } + + private _processError(e: unknown, type: DexErrorType): never { + if (isDexError(e)) { + throw e; + } + + throw new DexError(e, type); + } +} diff --git a/src/data/repositories/dex/transactionBuilders.ts b/src/data/repositories/dex/transactionBuilders.ts new file mode 100644 index 0000000..7679a78 --- /dev/null +++ b/src/data/repositories/dex/transactionBuilders.ts @@ -0,0 +1,149 @@ +import { + Args, + ContractCallBuilder, + ContractHash, + Deploy, + DeployHeader, + Duration, + ExecutableDeployItem, + PublicKey, + RpcClient, + SessionBuilder, + StoredVersionedContractByHash, + Transaction, +} from 'casper-js-sdk'; + +import { DEX_TRANSACTION_TTL_MS } from '../../../domain'; + +export const createContractPackageCallTransaction = (params: { + publicKey: string; + chainName: string; + entryPoint: string; + paymentMotes: string; + runtimeArgs: Args; + contractPackageHash: string; + gasPriceTolerance: number; +}): Transaction => { + const { + publicKey, + chainName, + entryPoint, + paymentMotes, + runtimeArgs, + contractPackageHash, + gasPriceTolerance, + } = params; + + return new ContractCallBuilder() + .from(PublicKey.fromHex(publicKey)) + .byPackageHash(contractPackageHash.replace('hash-', '')) + .entryPoint(entryPoint) + .runtimeArgs(runtimeArgs) + .chainName(chainName) + .payment(Number(paymentMotes), gasPriceTolerance) + .ttl(DEX_TRANSACTION_TTL_MS) + .build(); +}; + +/** Nodes still on Casper 1.5 need the legacy `buildFor1_5()` encoding, hence the status probe. */ +export const createSessionWasmTransaction = async (params: { + publicKey: string; + chainName: string; + paymentMotes: string; + wasmBinary: Uint8Array; + runtimeArgs: Args; + gasPriceTolerance: number; + rpcClient: RpcClient; +}): Promise => { + const { + publicKey, + chainName, + paymentMotes, + wasmBinary, + runtimeArgs, + gasPriceTolerance, + rpcClient, + } = params; + + const status = await rpcClient.getStatus(); + const apiVersion = status.apiVersion.startsWith('2.') ? 2 : 1; + + const sessionWasm = new SessionBuilder() + .from(PublicKey.fromHex(publicKey)) + .chainName(chainName) + .payment(Number(paymentMotes), gasPriceTolerance) + .ttl(DEX_TRANSACTION_TTL_MS) + .wasm(wasmBinary) + .installOrUpgrade() + .runtimeArgs(runtimeArgs); + + if (apiVersion === 2) { + return sessionWasm.build(); + } + + return sessionWasm.buildFor1_5(); +}; + +/** Legacy-Deploy counterpart of {@link createContractPackageCallTransaction}. */ +export const createContractDeploy = (params: { + publicKey: string; + chainName: string; + paymentMotes: string; + entryPoint: string; + runtimeArgs: Args; + contractPackageHash: string; + gasPriceTolerance: number; +}): Deploy => { + const { + publicKey, + chainName, + paymentMotes, + entryPoint, + runtimeArgs, + contractPackageHash, + gasPriceTolerance, + } = params; + + const deployHeader = DeployHeader.default(); + deployHeader.chainName = chainName; + deployHeader.account = PublicKey.fromHex(publicKey); + deployHeader.gasPrice = gasPriceTolerance; + deployHeader.ttl = new Duration(DEX_TRANSACTION_TTL_MS); + + const contractHash = ContractHash.newContract(contractPackageHash.replace('hash-', '')); + + const payment = ExecutableDeployItem.standardPayment(paymentMotes); + + const session = new ExecutableDeployItem(); + session.storedVersionedContractByHash = new StoredVersionedContractByHash( + contractHash, + entryPoint, + runtimeArgs, + ); + + return Deploy.makeDeploy(deployHeader, payment, session); +}; + +/** Legacy-Deploy counterpart of {@link createSessionWasmTransaction}. */ +export const createWASMContractDeploy = (params: { + publicKey: string; + chainName: string; + paymentMotes: string; + wasmBinary: Uint8Array; + runtimeArgs: Args; + gasPriceTolerance: number; +}): Deploy => { + const { publicKey, chainName, paymentMotes, wasmBinary, runtimeArgs, gasPriceTolerance } = params; + + const deployHeader = DeployHeader.default(); + deployHeader.chainName = chainName; + deployHeader.account = PublicKey.fromHex(publicKey); + deployHeader.gasPrice = gasPriceTolerance; + deployHeader.ttl = new Duration(DEX_TRANSACTION_TTL_MS); + + const payment = ExecutableDeployItem.standardPayment(paymentMotes); + + const session = ExecutableDeployItem.newModuleBytes(wasmBinary, runtimeArgs); + + return Deploy.makeDeploy(deployHeader, payment, session); +}; diff --git a/src/data/repositories/index.ts b/src/data/repositories/index.ts index 9b2155f..f57a1e2 100644 --- a/src/data/repositories/index.ts +++ b/src/data/repositories/index.ts @@ -8,3 +8,7 @@ export * from './appEvents'; export * from './txSignatureRequest'; export * from './contractPackage'; export * from './eip712'; +export * from './swap'; +export * from './dex'; +export * from './casperTransactions'; +export * from './transactionStatus'; diff --git a/src/data/repositories/onRamp/types.ts b/src/data/repositories/onRamp/types.ts index 65e5cbf..bd2ca4a 100644 --- a/src/data/repositories/onRamp/types.ts +++ b/src/data/repositories/onRamp/types.ts @@ -1,13 +1,20 @@ -import { IOnRampCurrencyItem, IOnRampProvider } from '../../../domain'; +import { IOnRampProvider } from '../../../domain'; export interface IGetOnRampResponse { countries: IResponseCountry[]; defaultCountry: string; - currencies: IOnRampCurrencyItem[]; + currencies: IOnRampCurrencyItemResponse[]; defaultCurrency: string; defaultAmount: string; } +export interface IOnRampCurrencyItemResponse { + id: number; + code: string; + type_id: string; + rate: number; +} + export interface IResponseCountry { name: string; code: string; @@ -15,7 +22,7 @@ export interface IResponseCountry { export interface IOnRampProvidersResponse { availableProviders: IOnRampProvider[]; - currencies: IOnRampCurrencyItem[]; + currencies: IOnRampCurrencyItemResponse[]; fiatAmount: number; fiatCurrency: string; cryptoAmount: number; diff --git a/src/data/repositories/swap/index.ts b/src/data/repositories/swap/index.ts new file mode 100644 index 0000000..e642f38 --- /dev/null +++ b/src/data/repositories/swap/index.ts @@ -0,0 +1,113 @@ +import { + CasperNetwork, + CSPR_NATIVE_TOKEN_ID, + DataResponse, + USD_CURRENCY_ID, + IDexToken, + IGetDexTokenParams, + IGetDexTokensParams, + IGetSwapQuoteParams, + isSwapError, + ISwapQuote, + ISwapRepository, + SwapError, + SwapErrorType, + ZERO_HASH, +} from '../../../domain'; +import type { IHttpDataProvider } from '../../../domain'; +import { DexTokenDto, SwapQuoteDto } from '../../dto/swap'; +import { DexTokenApiResponse, RawSwapQuote } from './types'; + +export * from './types'; + +// These requests go straight to the trade API rather than through the Casper Wallet cloud API, +// so CSPR_API_PROXY_HEADERS do not apply. +export class SwapRepository implements ISwapRepository { + constructor( + private _httpProvider: IHttpDataProvider, + private _tradeApiUrl: Record, + private _wrappedCsprContractPackageHash: Record, + ) {} + + async getQuote(params: IGetSwapQuoteParams): Promise { + const { network, tokenIn, tokenOut, amount, typeId } = params; + + try { + const baseUrl = this._resolveBaseUrl(network, 'getQuote'); + + const resp = await this._httpProvider.get>({ + url: `${baseUrl}/quote`, + params: { + token_in: tokenIn.id === CSPR_NATIVE_TOKEN_ID ? ZERO_HASH : tokenIn.packageHash, + token_out: tokenOut.id === CSPR_NATIVE_TOKEN_ID ? ZERO_HASH : tokenOut.packageHash, + amount, + type_id: typeId, + }, + errorType: 'getQuote', + }); + + return new SwapQuoteDto(resp!.data, tokenIn, tokenOut, typeId); + } catch (e) { + this._processError(e, 'getQuote'); + } + } + + async getDexTokens(params: IGetDexTokensParams): Promise { + const { network } = params; + + try { + const baseUrl = this._resolveBaseUrl(network, 'getDexTokens'); + + const resp = await this._httpProvider.get>({ + url: `${baseUrl}/tokens?includes=token_market_data(${USD_CURRENCY_ID}),total_value_locked`, + params: { + is_whitelisted: true, + is_blacklisted: false, + }, + errorType: 'getDexTokens', + }); + + return (resp?.data ?? []).map( + token => new DexTokenDto(token, this._wrappedCsprContractPackageHash[network]), + ); + } catch (e) { + this._processError(e, 'getDexTokens'); + } + } + + async getDexToken(params: IGetDexTokenParams): Promise { + const { network, contractPackageHash } = params; + + try { + const baseUrl = this._resolveBaseUrl(network, 'getDexToken'); + + const resp = await this._httpProvider.get>({ + url: `${baseUrl}/tokens/${contractPackageHash}?includes=token_market_data(${USD_CURRENCY_ID}),total_value_locked`, + errorType: 'getDexToken', + }); + + return new DexTokenDto(resp!.data, this._wrappedCsprContractPackageHash[network]); + } catch (e) { + this._processError(e, 'getDexToken'); + } + } + + /** Throws a `SwapError` instead of silently requesting against an empty base URL. */ + private _resolveBaseUrl(network: CasperNetwork, type: SwapErrorType): string { + const baseUrl = this._tradeApiUrl[network]; + + if (!baseUrl) { + throw new SwapError(new Error(`No trade API URL configured for network "${network}"`), type); + } + + return baseUrl; + } + + private _processError(e: unknown, type: SwapErrorType): never { + if (isSwapError(e)) { + throw e; + } + + throw new SwapError(e, type); + } +} diff --git a/src/data/repositories/swap/swap.test.ts b/src/data/repositories/swap/swap.test.ts new file mode 100644 index 0000000..67f69c4 --- /dev/null +++ b/src/data/repositories/swap/swap.test.ts @@ -0,0 +1,330 @@ +import { SwapRepository } from './index'; +import { createMockHttpProvider } from '../../../__test-utils__'; +import { + HttpClientError, + SwapError, + SwapQuoteType, + TradeApiUrl, + WrappedCsprContractPackageHash, +} from '../../../domain'; +import type { DexContractPackage, DexTokenApiResponse, RawSwapQuote } from './types'; + +const WCSPR_HASH = WrappedCsprContractPackageHash.mainnet; + +const makeContractPackage = (overrides: Partial = {}): DexContractPackage => ({ + contract_package_hash: 'cph_' + 'a'.repeat(60), + name: 'Sample Token', + metadata: { + decimals: 9, + name: 'Sample Token', + symbol: 'STK', + }, + icon_url: 'https://example.com/icon.png', + token_market_data: [ + { + currency_id: 1, + dex_id: 1, + latest_rate: 0.5, + timestamp: '2024-01-01T00:00:00.000Z', + token_contract_package_hash: 'cph_' + 'a'.repeat(60), + token_volume_24h: '1000', + volume_24h: '1000', + }, + ], + ...overrides, +}); + +const makeDexTokenApiResponse = ( + overrides: Partial = {}, +): DexTokenApiResponse => ({ + contract_package_hash: 'cph_' + 'a'.repeat(60), + contract_package: makeContractPackage(), + is_blacklisted: false, + is_whitelisted: true, + sorting_order: 1, + total_value_locked: '5000', + ...overrides, +}); + +const makeRawSwapQuote = (overrides: Partial = {}): RawSwapQuote => ({ + amount_in: '2000000000', + amount_out: '1000000000', + execution_price: '0.5', + mid_price: '0.5', + path: ['token-in-hash', 'token-out-hash'], + price_impact: '0.01', + recommended_slippage_bps: '50', + type_id: SwapQuoteType.ExactIn, + ...overrides, +}); + +describe('SwapRepository', () => { + describe('getQuote', () => { + it('sends the quote request with package hashes, amount, and type_id', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: makeRawSwapQuote() }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const tokenIn = { id: 'token-in-id', packageHash: 'token-in-hash', decimals: 9 } as never; + const tokenOut = { + id: 'token-out-id', + packageHash: 'token-out-hash', + decimals: 9, + } as never; + + await repo.getQuote({ + network: 'mainnet', + tokenIn, + tokenOut, + amount: '2000000000', + typeId: SwapQuoteType.ExactIn, + }); + + expect(http.get).toHaveBeenCalledTimes(1); + const arg = http.get.mock.calls[0][0]; + expect(arg.url).toBe('https://api.cspr.trade/quote'); + expect(arg.params).toEqual({ + token_in: 'token-in-hash', + token_out: 'token-out-hash', + amount: '2000000000', + type_id: SwapQuoteType.ExactIn, + }); + }); + + it('sends ZERO_HASH for the CSPR leg of the quote', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: makeRawSwapQuote() }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const csprToken = { id: 'cspr', packageHash: WCSPR_HASH, decimals: 9 } as never; + const tokenOut = { + id: 'token-out-id', + packageHash: 'token-out-hash', + decimals: 9, + } as never; + + await repo.getQuote({ + network: 'mainnet', + tokenIn: csprToken, + tokenOut, + amount: '2000000000', + typeId: SwapQuoteType.ExactIn, + }); + + const arg = http.get.mock.calls[0][0]; + expect(arg.params?.token_in).toBe( + '0000000000000000000000000000000000000000000000000000000000000000', + ); + }); + + it('derives amountInDecimal, amountOutDecimal, and rate from the raw quote', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ + data: makeRawSwapQuote({ amount_in: '2000000000', amount_out: '1000000000' }), + }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const tokenIn = { id: 'token-in-id', packageHash: 'token-in-hash', decimals: 9 } as never; + const tokenOut = { + id: 'token-out-id', + packageHash: 'token-out-hash', + decimals: 9, + } as never; + + const quote = await repo.getQuote({ + network: 'mainnet', + tokenIn, + tokenOut, + amount: '2000000000', + typeId: SwapQuoteType.ExactIn, + }); + + expect(quote.amountInDecimal).toBe('2'); + expect(quote.amountOutDecimal).toBe('1'); + expect(quote.rate).toBe('0.5'); + }); + }); + + describe('getDexTokens', () => { + it('filters by whitelist/blacklist and requests token_market_data + total_value_locked', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: [makeDexTokenApiResponse()] }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + await repo.getDexTokens({ network: 'mainnet' }); + + const arg = http.get.mock.calls[0][0]; + expect(arg.url).toBe( + 'https://api.cspr.trade/tokens?includes=token_market_data(1),total_value_locked', + ); + expect(arg.params).toEqual({ is_whitelisted: true, is_blacklisted: false }); + }); + + it('maps the WCSPR record to the synthetic native CSPR token', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ + data: [ + makeDexTokenApiResponse({ + contract_package_hash: WCSPR_HASH, + contract_package: makeContractPackage({ contract_package_hash: WCSPR_HASH }), + }), + ], + }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const [token] = await repo.getDexTokens({ network: 'mainnet' }); + + expect(token).toMatchObject({ + id: 'cspr', + name: 'Casper', + symbol: 'CSPR', + packageHash: WCSPR_HASH, + }); + }); + + it('maps an ordinary token by its own contract_package_hash and metadata', async () => { + const http = createMockHttpProvider(); + const cph = 'cph_' + 'b'.repeat(60); + http.get.mockResolvedValueOnce({ + data: [ + makeDexTokenApiResponse({ + contract_package_hash: cph, + contract_package: makeContractPackage({ + contract_package_hash: cph, + metadata: { + decimals: 6, + name: 'Other Token', + symbol: 'OTK', + }, + }), + }), + ], + }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const [token] = await repo.getDexTokens({ network: 'mainnet' }); + + expect(token).toMatchObject({ + id: cph, + name: 'Other Token', + symbol: 'OTK', + decimals: 6, + packageHash: cph, + }); + }); + + it('picks the lowest dex_id token_market_data entry for fiatRates and volume24h', async () => { + const http = createMockHttpProvider(); + const cph = 'cph_' + 'd'.repeat(60); + http.get.mockResolvedValueOnce({ + data: [ + makeDexTokenApiResponse({ + contract_package_hash: cph, + contract_package: makeContractPackage({ + contract_package_hash: cph, + token_market_data: [ + { + currency_id: 1, + dex_id: 3, + latest_rate: 9, + timestamp: '2024-01-01T00:00:00.000Z', + token_contract_package_hash: cph, + token_volume_24h: '30', + volume_24h: '300', + }, + { + currency_id: 1, + dex_id: 1, + latest_rate: 7, + timestamp: '2024-01-01T00:00:00.000Z', + token_contract_package_hash: cph, + token_volume_24h: '10', + volume_24h: '100', + }, + ], + }), + }), + ], + }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const [token] = await repo.getDexTokens({ network: 'mainnet' }); + + expect(token).toMatchObject({ fiatRates: 7, volume24h: '100' }); + }); + }); + + describe('getDexToken', () => { + it('fetches a single token by hash with the same DTO mapping', async () => { + const http = createMockHttpProvider(); + const cph = 'cph_' + 'c'.repeat(60); + http.get.mockResolvedValueOnce({ + data: makeDexTokenApiResponse({ + contract_package_hash: cph, + contract_package: makeContractPackage({ contract_package_hash: cph }), + }), + }); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + const token = await repo.getDexToken({ + network: 'mainnet', + contractPackageHash: cph, + }); + + const arg = http.get.mock.calls[0][0]; + expect(arg.url).toBe( + `https://api.cspr.trade/tokens/${cph}?includes=token_market_data(1),total_value_locked`, + ); + expect(token.id).toBe(cph); + }); + }); + + describe('error wrapping', () => { + it('wraps a non-domain rejection in a SwapError typed after the failing method', async () => { + const http = createMockHttpProvider(); + http.get.mockRejectedValueOnce(new Error('boom')); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + await expect(repo.getDexTokens({ network: 'mainnet' })).rejects.toMatchObject({ + type: 'getDexTokens', + }); + }); + + it('carries the response envelope and status over from a wrapped HttpError', async () => { + const http = createMockHttpProvider(); + http.get.mockRejectedValueOnce( + new HttpClientError('Bad Request', { + type: 'getQuote', + status: 400, + data: JSON.stringify({ status: 400, data: { error: { code: 'invalid_input' } } }), + }), + ); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + await expect(repo.getDexTokens({ network: 'mainnet' })).rejects.toMatchObject({ + type: 'getDexTokens', + status: 400, + data: expect.stringContaining('invalid_input'), + }); + }); + + it('rethrows an inner SwapError as-is instead of wrapping it again', async () => { + const http = createMockHttpProvider(); + const innerError = new SwapError(new Error('already a swap error'), 'getQuote'); + http.get.mockRejectedValueOnce(innerError); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + await expect(repo.getDexTokens({ network: 'mainnet' })).rejects.toBe(innerError); + }); + }); + + describe('network without a configured URL', () => { + it('throws a SwapError instead of requesting against an empty base URL', async () => { + const http = createMockHttpProvider(); + const repo = new SwapRepository(http, TradeApiUrl, WrappedCsprContractPackageHash); + + await expect(repo.getDexTokens({ network: 'devnet' })).rejects.toBeInstanceOf(SwapError); + expect(http.get).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/data/repositories/swap/types.ts b/src/data/repositories/swap/types.ts new file mode 100644 index 0000000..05fd29d --- /dev/null +++ b/src/data/repositories/swap/types.ts @@ -0,0 +1,39 @@ +import type { Maybe } from '../../../typings'; +import type { ITokenMarketData } from '../contractPackage'; + +/** Raw record shape returned by GET /tokens and GET /tokens/{hash}. */ +export interface DexTokenApiResponse { + contract_package_hash: string; + contract_package: DexContractPackage; + is_blacklisted: boolean; + is_whitelisted: boolean; + sorting_order: number; + total_value_locked: string; +} + +/** Fields of the nested `contract_package` record that `DexTokenDto` reads. */ +export interface DexContractPackage { + contract_package_hash: string; + name: string; + metadata: DexTokenMetadata; + icon_url: Maybe; + token_market_data: Maybe; +} + +export interface DexTokenMetadata { + decimals: number; + name: string; + symbol: string; +} + +/** Raw GET /quote response, before the client-side amountInDecimal/amountOutDecimal/rate derivations. */ +export interface RawSwapQuote { + amount_in: string; + amount_out: string; + execution_price: string; + mid_price: string; + path: string[]; + price_impact: string; + recommended_slippage_bps: string; + type_id: number; +} diff --git a/src/data/repositories/tokens/index.ts b/src/data/repositories/tokens/index.ts index 24f508e..8641d9c 100644 --- a/src/data/repositories/tokens/index.ts +++ b/src/data/repositories/tokens/index.ts @@ -4,6 +4,7 @@ import { TokensError, HttpClientNotFoundError, CSPR_API_PROXY_HEADERS, + USD_CURRENCY_ID, ITokensRepository, DataResponse, IGetTokensParams, @@ -30,16 +31,23 @@ export class TokensRepository implements ITokensRepository { network, publicKey, withProxyHeader = true, + contractPackageHashes, }: IGetTokensParams): Promise { try { const accountHash = getAccountHashFromPublicKey(publicKey); + const params: Record = { + page_size: 100, // TODO pagination? + includes: `contract_package,token_market_data(${USD_CURRENCY_ID})`, + }; + + if (contractPackageHashes?.length) { + params.contract_package_hash = contractPackageHashes.join(','); + } + const tokensList = await this._httpProvider.get>({ url: `${this._casperWalletApiUrl[network]}/accounts/${accountHash}/ft-token-ownership`, - params: { - page_size: 100, // TODO pagination? - includes: 'contract_package,token_market_data(1)', - }, + params, ...(withProxyHeader ? { headers: CSPR_API_PROXY_HEADERS } : {}), errorType: 'getTokens', }); diff --git a/src/data/repositories/tokens/tokens.test.ts b/src/data/repositories/tokens/tokens.test.ts index f6d7d34..79505ba 100644 --- a/src/data/repositories/tokens/tokens.test.ts +++ b/src/data/repositories/tokens/tokens.test.ts @@ -33,6 +33,44 @@ describe('TokensRepository', () => { expect(tokens).toHaveLength(2); }); + it('narrows the request to the given contract packages', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: [] }); + const repo = new TokensRepository(http, CasperWalletApiByNetworkUrl); + + await repo.getTokens({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + contractPackageHashes: ['cph-1', 'cph-2'], + }); + + expect(http.get.mock.calls[0][0].params?.contract_package_hash).toBe('cph-1,cph-2'); + }); + + it('asks for every held token when no contract packages are given', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: [] }); + const repo = new TokensRepository(http, CasperWalletApiByNetworkUrl); + + await repo.getTokens({ network: 'mainnet', publicKey: PUBLIC_KEY }); + + expect(http.get.mock.calls[0][0].params).not.toHaveProperty('contract_package_hash'); + }); + + it('treats an empty contract-package list as no filter, not as "match nothing"', async () => { + const http = createMockHttpProvider(); + http.get.mockResolvedValueOnce({ data: [] }); + const repo = new TokensRepository(http, CasperWalletApiByNetworkUrl); + + await repo.getTokens({ + network: 'mainnet', + publicKey: PUBLIC_KEY, + contractPackageHashes: [], + }); + + expect(http.get.mock.calls[0][0].params).not.toHaveProperty('contract_package_hash'); + }); + it('omits proxy header when withProxyHeader is false', async () => { const http = createMockHttpProvider(); http.get.mockResolvedValueOnce({ data: [] }); diff --git a/src/data/repositories/transactionStatus/index.ts b/src/data/repositories/transactionStatus/index.ts new file mode 100644 index 0000000..3a42e21 --- /dev/null +++ b/src/data/repositories/transactionStatus/index.ts @@ -0,0 +1,158 @@ +import { + catchError, + defer, + first, + firstValueFrom, + merge, + Observable, + of, + repeat, + take, + tap, + throwError, + timeout, +} from 'rxjs'; + +import { createCasperRpcClient } from '../../../utils/casperSdk/rpcClient'; +import { + DEFAULT_LOOKUP_GRACE_MS, + DEFAULT_SETTLEMENT_POLL_INTERVAL_MS, + DEFAULT_SETTLEMENT_TIMEOUT_MS, + TransactionStatusError, + TransactionTimeoutError, + TransactionWatchCancelledError, +} from '../../../domain/transactionStatus'; +import type { + ITransactionOutcome, + ITransactionStatusRepository, + IWaitForTransactionParams, +} from '../../../domain/transactionStatus'; +import type { CasperNetwork, ICasperRpcOptions } from '../../../domain'; + +type RpcClient = ReturnType; + +/** `ErrorCode.NoSuchDeploy` / `ErrorCode.NoSuchTransaction` — the node has not seen the hash yet. */ +const PENDING_RPC_CODES = [-32000, -32014]; + +/** + * The sdk reports an RPC error as `HttpError(code, RpcError)`, so the code sits on `statusCode` + * and on the wrapped `sourceErr` — never on the thrown error itself. + */ +const rpcErrorCodes = (error: unknown): number[] => { + if (typeof error !== 'object' || error === null) { + return []; + } + + const { code, statusCode, sourceErr } = error as { + code?: unknown; + statusCode?: unknown; + sourceErr?: { code?: unknown }; + }; + + return [code, statusCode, sourceErr?.code].filter(value => value != null).map(Number); +}; + +const isPendingRpcError = (error: unknown): boolean => + rpcErrorCodes(error).some(code => PENDING_RPC_CODES.includes(code)); + +/** Turns "the node has not seen the hash yet" into an empty lookup; rethrows anything else. */ +const pendingOrRethrow = (error: unknown): undefined => { + if (isPendingRpcError(error)) { + return undefined; + } + + throw error; +}; + +/** Errors on abort and never emits, so merging it into a watch cancels the watch. */ +const abortAsError$ = (signal: AbortSignal, hash: string): Observable => + new Observable(subscriber => { + const fail = () => subscriber.error(new TransactionWatchCancelledError(hash)); + + signal.addEventListener('abort', fail, { once: true }); + + return () => signal.removeEventListener('abort', fail); + }); + +export class TransactionStatusRepository implements ITransactionStatusRepository { + constructor( + private _grpcUrl: Record, + private _rpcOptions: ICasperRpcOptions = {}, + ) {} + + observeTransaction(params: IWaitForTransactionParams): Observable { + const { + hash, + network, + signal, + pollIntervalMs = DEFAULT_SETTLEMENT_POLL_INTERVAL_MS, + timeoutMs = DEFAULT_SETTLEMENT_TIMEOUT_MS, + lookupGraceMs = DEFAULT_LOOKUP_GRACE_MS, + } = params; + + const maxConsecutiveFailures = Math.max(1, Math.ceil(lookupGraceMs / pollIntervalMs)); + + const poll$ = defer((): Observable => { + if (signal?.aborted) { + return throwError(() => new TransactionWatchCancelledError(hash)); + } + + const rpcClient = createCasperRpcClient(this._grpcUrl[network], this._rpcOptions); + let consecutiveFailures = 0; + + return defer(() => this._lookup(rpcClient, params)).pipe( + tap(() => { + consecutiveFailures = 0; + }), + // A single failed lookup says nothing about the transaction, so the node is only called + // unreachable once the grace window of back-to-back failures is spent. + catchError((error: unknown) => { + consecutiveFailures += 1; + + return consecutiveFailures > maxConsecutiveFailures + ? throwError(() => new TransactionStatusError(error, 'lookup')) + : of(null); + }), + // `repeat` waits out the interval after each lookup returns. A timer-driven poll would + // instead queue every tick a slow lookup overran and then fire them back to back. + repeat({ delay: pollIntervalMs }), + first((outcome): outcome is ITransactionOutcome => outcome !== null), + timeout({ + each: timeoutMs, + with: () => throwError(() => new TransactionTimeoutError(hash)), + }), + ); + }); + + // `abortAsError$` never completes on its own, so `take(1)` is what ends the merge and + // removes the abort listener. + return signal ? merge(poll$, abortAsError$(signal, hash)).pipe(take(1)) : poll$; + } + + waitForTransaction(params: IWaitForTransactionParams): Promise { + return firstValueFrom(this.observeTransaction(params)); + } + + /** `null` means "not executed yet" — the caller polls again. */ + private async _lookup( + rpcClient: RpcClient, + { hash, isDeploy }: IWaitForTransactionParams, + ): Promise { + const result = isDeploy + ? await rpcClient.getTransactionByDeployHash(hash).catch(pendingOrRethrow) + : await rpcClient.getTransactionByTransactionHash(hash).catch(pendingOrRethrow); + + const executionInfo = result?.executionInfo; + + if (!executionInfo) { + return null; + } + + const errorMessage = executionInfo.executionResult?.errorMessage; + const blockHeight = executionInfo.blockHeight; + + return errorMessage + ? { hash, status: 'failure', blockHeight, errorMessage } + : { hash, status: 'success', blockHeight }; + } +} diff --git a/src/data/repositories/transactionStatus/transactionStatus.test.ts b/src/data/repositories/transactionStatus/transactionStatus.test.ts new file mode 100644 index 0000000..d63c3de --- /dev/null +++ b/src/data/repositories/transactionStatus/transactionStatus.test.ts @@ -0,0 +1,341 @@ +import { HttpError, RpcError } from 'casper-js-sdk'; +import { firstValueFrom } from 'rxjs'; + +import { TransactionStatusRepository } from './index'; + +import { createCasperRpcClient } from '../../../utils/casperSdk/rpcClient'; + +import { + DEFAULT_SETTLEMENT_POLL_INTERVAL_MS, + DEFAULT_SETTLEMENT_TIMEOUT_MS, + DEX_TRANSACTION_TTL_MS, + isTransactionTimeoutError, + isTransactionWatchCancelledError, + TransactionStatusError, +} from '../../../domain'; +import type { CasperNetwork } from '../../../domain'; + +jest.mock('../../../utils/casperSdk/rpcClient', () => ({ + createCasperRpcClient: jest.fn(), +})); + +const createCasperRpcClientMock = jest.mocked(createCasperRpcClient); + +const NETWORK: CasperNetwork = 'testnet'; +const HASH = 'aa'.repeat(32); +const GRPC_URL = { mainnet: 'm', testnet: 't', devnet: 'd', integration: 'i' } as Record< + CasperNetwork, + string +>; + +const settled = (blockHeight: number, errorMessage?: string) => ({ + executionInfo: { blockHeight, executionResult: { errorMessage } }, +}); + +const pending = () => ({ executionInfo: undefined }); + +/** The shape `RpcClient.processRequest` throws: the code rides on the wrapped `RpcError`. */ +const rpcError = (code: number) => new HttpError(code, new RpcError(code, `rpc ${code}`)); + +/** Installs a fake RpcClient whose lookups resolve/reject from `steps`, in order. */ +const installRpc = (steps: Array<() => Promise>) => { + let call = 0; + const next = () => steps[Math.min(call++, steps.length - 1)](); + const client = { + getTransactionByTransactionHash: jest.fn(next), + getTransactionByDeployHash: jest.fn(next), + }; + + createCasperRpcClientMock.mockReturnValue(client as never); + + return client; +}; + +const makeRepository = () => new TransactionStatusRepository(GRPC_URL, {}); + +const params = ( + over: Partial<{ + isDeploy: boolean; + timeoutMs: number; + lookupGraceMs: number; + signal: AbortSignal; + }> = {}, +) => ({ + hash: HASH, + network: NETWORK, + isDeploy: false, + pollIntervalMs: 5, + timeoutMs: 500, + ...over, +}); + +beforeEach(() => { + createCasperRpcClientMock.mockClear(); +}); + +describe('TransactionStatusRepository', () => { + it('emits a success outcome when the transaction executed without an error message', async () => { + installRpc([async () => settled(42)]); + + await expect(makeRepository().waitForTransaction(params())).resolves.toEqual({ + hash: HASH, + status: 'success', + blockHeight: 42, + }); + }); + + it('emits a failure outcome carrying the node error message when execution reverted', async () => { + installRpc([async () => settled(43, 'User error: 65534')]); + + await expect(makeRepository().waitForTransaction(params())).resolves.toEqual({ + hash: HASH, + status: 'failure', + blockHeight: 43, + errorMessage: 'User error: 65534', + }); + }); + + it('keeps polling while the transaction is accepted but not yet executed', async () => { + const client = installRpc([ + async () => pending(), + async () => pending(), + async () => settled(7), + ]); + + await expect(makeRepository().waitForTransaction(params())).resolves.toEqual({ + hash: HASH, + status: 'success', + blockHeight: 7, + }); + expect(client.getTransactionByTransactionHash).toHaveBeenCalledTimes(3); + }); + + it.each([ + ['NoSuchDeploy', -32000], + ['NoSuchTransaction', -32014], + ])('treats a %s rejection as still pending', async (_name, code) => { + installRpc([ + async () => { + throw rpcError(code); + }, + async () => settled(9), + ]); + + await expect(makeRepository().waitForTransaction(params())).resolves.toEqual({ + hash: HASH, + status: 'success', + blockHeight: 9, + }); + }); + + it('keeps polling through a run of transient lookup failures rather than ending the watch', async () => { + let calls = 0; + + installRpc([ + async () => { + if ((calls += 1) <= 6) { + throw new Error('socket hang up'); + } + + return settled(11); + }, + ]); + + await expect( + makeRepository().waitForTransaction(params({ timeoutMs: 2_500, lookupGraceMs: 200 })), + ).resolves.toEqual({ + hash: HASH, + status: 'success', + blockHeight: 11, + }); + }); + + it('errors with a lookup TransactionStatusError once the grace window of failures elapses', async () => { + installRpc([ + async () => { + throw new Error('ECONNREFUSED'); + }, + ]); + + const error = await makeRepository() + .waitForTransaction(params({ lookupGraceMs: 20 })) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(TransactionStatusError); + expect((error as { type: string }).type).toBe('lookup'); + }); + + it('errors with a timeout — not a failure outcome — when the transaction never executes', async () => { + installRpc([async () => pending()]); + + const error = await makeRepository() + .waitForTransaction(params({ timeoutMs: 60 })) + .catch((e: unknown) => e); + + expect(isTransactionTimeoutError(error)).toBe(true); + expect((error as { hash: string }).hash).toBe(HASH); + expect((error as { type: string }).type).toBe('timeout'); + }); + + it('looks a legacy deploy up by deploy hash and never by transaction hash', async () => { + const client = installRpc([async () => settled(1)]); + + await makeRepository().waitForTransaction(params({ isDeploy: true })); + + expect(client.getTransactionByDeployHash).toHaveBeenCalledWith(HASH); + expect(client.getTransactionByTransactionHash).not.toHaveBeenCalled(); + }); + + it('looks a TransactionV1 up by transaction hash and never by deploy hash', async () => { + const client = installRpc([async () => settled(1)]); + + await makeRepository().waitForTransaction(params({ isDeploy: false })); + + expect(client.getTransactionByTransactionHash).toHaveBeenCalledWith(HASH); + expect(client.getTransactionByDeployHash).not.toHaveBeenCalled(); + }); + + it('never runs two lookups concurrently, even when one outlives the poll interval', async () => { + let inFlight = 0; + let maxInFlight = 0; + let calls = 0; + + createCasperRpcClientMock.mockReturnValue({ + getTransactionByTransactionHash: jest.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 30)); + inFlight -= 1; + + return (calls += 1) < 3 ? pending() : settled(5); + }), + getTransactionByDeployHash: jest.fn(), + } as never); + + await makeRepository().waitForTransaction(params({ timeoutMs: 2_000 })); + + expect(maxInFlight).toBe(1); + }); + + it('waits out the poll interval after a slow lookup instead of queueing missed ticks', async () => { + const LOOKUP_MS = 60; + const POLL_INTERVAL_MS = 50; + const starts: number[] = []; + let calls = 0; + + createCasperRpcClientMock.mockReturnValue({ + getTransactionByTransactionHash: jest.fn(async () => { + starts.push(Date.now()); + await new Promise(resolve => setTimeout(resolve, LOOKUP_MS)); + + return (calls += 1) < 3 ? pending() : settled(5); + }), + getTransactionByDeployHash: jest.fn(), + } as never); + + await makeRepository().waitForTransaction({ + ...params({ timeoutMs: 5_000 }), + pollIntervalMs: POLL_INTERVAL_MS, + }); + + expect(starts).toHaveLength(3); + expect(starts[1] - starts[0]).toBeGreaterThanOrEqual(LOOKUP_MS + POLL_INTERVAL_MS - 15); + expect(starts[2] - starts[1]).toBeGreaterThanOrEqual(LOOKUP_MS + POLL_INTERVAL_MS - 15); + }); + + it('builds one rpc client per watch rather than one per poll', async () => { + const client = installRpc([ + async () => pending(), + async () => pending(), + async () => settled(3), + ]); + + await makeRepository().waitForTransaction(params()); + + expect(client.getTransactionByTransactionHash).toHaveBeenCalledTimes(3); + expect(createCasperRpcClientMock).toHaveBeenCalledTimes(1); + }); + + it('rejects with a cancellation error when the caller aborts the watch', async () => { + installRpc([async () => pending()]); + const controller = new AbortController(); + + const promise = makeRepository() + .waitForTransaction(params({ timeoutMs: 5_000, signal: controller.signal })) + .catch((e: unknown) => e); + + await new Promise(resolve => setTimeout(resolve, 20)); + controller.abort(); + + const error = await promise; + + expect(isTransactionWatchCancelledError(error)).toBe(true); + expect((error as { hash: string }).hash).toBe(HASH); + }); + + it('stops polling once the watch is aborted', async () => { + const client = installRpc([async () => pending()]); + const controller = new AbortController(); + + const promise = makeRepository() + .waitForTransaction(params({ timeoutMs: 5_000, signal: controller.signal })) + .catch(() => undefined); + + await new Promise(resolve => setTimeout(resolve, 30)); + controller.abort(); + await promise; + + const callsAtAbort = client.getTransactionByTransactionHash.mock.calls.length; + + await new Promise(resolve => setTimeout(resolve, 60)); + + expect(client.getTransactionByTransactionHash).toHaveBeenCalledTimes(callsAtAbort); + }); + + it('rejects immediately when handed an already-aborted signal', async () => { + const client = installRpc([async () => settled(1)]); + + const error = await makeRepository() + .waitForTransaction(params({ signal: AbortSignal.abort() })) + .catch((e: unknown) => e); + + expect(isTransactionWatchCancelledError(error)).toBe(true); + expect(client.getTransactionByTransactionHash).not.toHaveBeenCalled(); + }); + + it('exposes the same outcome through observeTransaction', async () => { + installRpc([async () => settled(42)]); + + await expect(firstValueFrom(makeRepository().observeTransaction(params()))).resolves.toEqual({ + hash: HASH, + status: 'success', + blockHeight: 42, + }); + }); + + it('completes observeTransaction after the outcome even when a signal is supplied', async () => { + installRpc([async () => settled(42)]); + const controller = new AbortController(); + const seen: string[] = []; + + await new Promise((resolve, reject) => { + makeRepository() + .observeTransaction(params({ signal: controller.signal })) + .subscribe({ + next: () => seen.push('next'), + error: reject, + complete: () => { + seen.push('complete'); + resolve(); + }, + }); + }); + + expect(seen).toEqual(['next', 'complete']); + }); + + it('watches for as long as a transaction stays valid on chain', () => { + expect(DEFAULT_SETTLEMENT_POLL_INTERVAL_MS).toBe(2_000); + expect(DEFAULT_SETTLEMENT_TIMEOUT_MS).toBe(DEX_TRANSACTION_TTL_MS); + }); +}); diff --git a/src/data/repositories/txSignatureRequest/index.ts b/src/data/repositories/txSignatureRequest/index.ts index 46a7e04..ed847b5 100644 --- a/src/data/repositories/txSignatureRequest/index.ts +++ b/src/data/repositories/txSignatureRequest/index.ts @@ -129,6 +129,7 @@ export class TxSignatureRequestRepository implements ITxSignatureRequestReposito await this._accountInfoRepository.getAccountsInfo({ network, accountHashes, + withProxyHeader, }); } catch {} diff --git a/src/data/repositories/txSignatureRequest/txSignatureRequest.test.ts b/src/data/repositories/txSignatureRequest/txSignatureRequest.test.ts index 4d04ce4..a4a7106 100644 --- a/src/data/repositories/txSignatureRequest/txSignatureRequest.test.ts +++ b/src/data/repositories/txSignatureRequest/txSignatureRequest.test.ts @@ -3,6 +3,8 @@ import { AccountInfoRepository } from '../accountInfo'; import { TokensRepository } from '../tokens'; import { ContractPackageRepository } from '../contractPackage'; import { createMockHttpProvider } from '../../../__test-utils__'; +import * as fs from 'fs'; +import * as path from 'path'; import { CasperWalletApiByEnvUrl, CasperWalletApiByNetworkUrl, @@ -13,6 +15,13 @@ import { const SIGNING_KEY = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; +const NATIVE_TRANSFER_TX = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, '../../../__fixtures__/transactions/cspr-native-transfer.json'), + 'utf8', + ), +); + describe('TxSignatureRequestRepository', () => { const buildRepo = () => { const http = createMockHttpProvider(); @@ -30,7 +39,7 @@ describe('TxSignatureRequestRepository', () => { CasperWalletApiByEnvUrl, GrpcUrl, ); - return { repo, http }; + return { repo, http, accountInfoRepository }; }; it('throws TxSignatureRequestError for invalid transaction JSON', async () => { @@ -55,6 +64,21 @@ describe('TxSignatureRequestRepository', () => { ).rejects.toBeDefined(); }); + it('keeps the proxy header off the nested accounts lookup', async () => { + const { repo, accountInfoRepository } = buildRepo(); + const accountsSpy = jest.spyOn(accountInfoRepository, 'getAccountsInfo').mockResolvedValue({}); + // The sender lookup that follows talks to a node over RPC; keep the test off the network. + jest.spyOn(global, 'fetch').mockRejectedValue(new Error('offline')); + + await repo.prepareSignatureRequest({ + transactionJson: NATIVE_TRANSFER_TX, + signingPublicKeyHex: SIGNING_KEY, + withProxyHeader: false, + }); + + expect(accountsSpy).toHaveBeenCalledWith(expect.objectContaining({ withProxyHeader: false })); + }); + it('returns a known error type', () => { expect(new InvalidTransactionJsonError('test')).toBeInstanceOf(Error); }); diff --git a/src/data/signers/index.ts b/src/data/signers/index.ts new file mode 100644 index 0000000..7ccd59f --- /dev/null +++ b/src/data/signers/index.ts @@ -0,0 +1,2 @@ +export * from './privateKeySigner'; +export * from './ledgerSigner'; diff --git a/src/data/signers/ledgerSigner.test.ts b/src/data/signers/ledgerSigner.test.ts new file mode 100644 index 0000000..2fd68b4 --- /dev/null +++ b/src/data/signers/ledgerSigner.test.ts @@ -0,0 +1,140 @@ +import { Transaction } from 'casper-js-sdk'; +import { createLedgerSigner } from './ledgerSigner'; +import { ICasperLedgerService, LedgerError, LedgerEventStatus } from '../../domain'; + +const publicKeyHex = '02abc'; +const derivationIndex = 3; +const tx = { hash: 'tx' } as unknown as Transaction; +const fallbackDeploy = { hash: 'deploy' } as unknown as Parameters< + typeof Transaction.fromDeploy +>[0]; + +const makeFakeService = (over: Record = {}) => ({ + signTransaction: jest.fn().mockResolvedValue({ + signatureHex: '01', + signature: new Uint8Array([1]), + prefixedSignatureHex: '0201', + prefixedSignature: new Uint8Array([2, 1]), + }), + getSignedTransaction: jest.fn().mockResolvedValue(tx), + signMessage: jest.fn().mockResolvedValue({ + signatureHex: '07', + signature: new Uint8Array([7]), + }), + ...over, +}); + +const makeSigner = ( + service: ReturnType, + over: Record = {}, +) => + createLedgerSigner({ + service: service as unknown as ICasperLedgerService, + publicKeyHex, + derivationIndex, + ...over, + }); + +describe('createLedgerSigner', () => { + it('signTransaction delegates to service.signTransaction and maps prefixedSignature to signatureWithPrefix', async () => { + const service = makeFakeService(); + const supportsTransactionV1Cb = jest.fn(); + const tryToRestoreSessionCb = jest.fn(); + const signer = makeSigner(service, { supportsTransactionV1Cb, tryToRestoreSessionCb }); + + const resp = await signer.signTransaction(tx); + + expect(service.signTransaction).toHaveBeenCalledWith( + tx, + { index: derivationIndex, publicKey: publicKeyHex }, + supportsTransactionV1Cb, + tryToRestoreSessionCb, + ); + expect(resp).toEqual({ + signature: new Uint8Array([1]), + signatureWithPrefix: new Uint8Array([2, 1]), + }); + }); + + it('signTransaction rethrows LedgerError untouched', async () => { + const ledgerError = new LedgerError({ status: LedgerEventStatus.SignatureFailed }); + const service = makeFakeService({ + signTransaction: jest.fn().mockRejectedValue(ledgerError), + }); + const signer = makeSigner(service); + + await expect(signer.signTransaction(tx)).rejects.toBe(ledgerError); + }); + + it('getSignedTransaction wraps a fallbackDeploy as a Transaction and forwards it', async () => { + const service = makeFakeService(); + const supportsTransactionV1Cb = jest.fn(); + const tryToRestoreSessionCb = jest.fn(); + const signer = makeSigner(service, { supportsTransactionV1Cb, tryToRestoreSessionCb }); + const fromDeploySpy = jest.spyOn(Transaction, 'fromDeploy').mockReturnValue(tx); + + const result = await signer.getSignedTransaction(tx, { fallbackDeploy }); + + expect(fromDeploySpy).toHaveBeenCalledWith(fallbackDeploy); + expect(service.getSignedTransaction).toHaveBeenCalledWith( + tx, + { index: derivationIndex, publicKey: publicKeyHex }, + tx, + supportsTransactionV1Cb, + tryToRestoreSessionCb, + ); + expect(result).toBe(tx); + + fromDeploySpy.mockRestore(); + }); + + it('getSignedTransaction forwards undefined when there is no fallbackDeploy', async () => { + const service = makeFakeService(); + const signer = makeSigner(service); + + await signer.getSignedTransaction(tx); + + expect(service.getSignedTransaction).toHaveBeenCalledWith( + tx, + { index: derivationIndex, publicKey: publicKeyHex }, + undefined, + undefined, + undefined, + ); + }); + + it('getSignedTransaction rethrows LedgerError untouched', async () => { + const ledgerError = new LedgerError({ status: LedgerEventStatus.TransactionForOldAppVersion }); + const service = makeFakeService({ + getSignedTransaction: jest.fn().mockRejectedValue(ledgerError), + }); + const signer = makeSigner(service); + + await expect(signer.getSignedTransaction(tx)).rejects.toBe(ledgerError); + }); + + it('signMessage delegates to service.signMessage and returns raw signature bytes', async () => { + const service = makeFakeService(); + const tryToRestoreSessionCb = jest.fn(); + const signer = makeSigner(service, { tryToRestoreSessionCb }); + + const signature = await signer.signMessage('hello'); + + expect(service.signMessage).toHaveBeenCalledWith( + 'hello', + { index: derivationIndex, publicKey: publicKeyHex }, + tryToRestoreSessionCb, + ); + expect(signature).toEqual(new Uint8Array([7])); + }); + + it('signMessage rethrows LedgerError untouched', async () => { + const ledgerError = new LedgerError({ status: LedgerEventStatus.MsgSignatureFailed }); + const service = makeFakeService({ + signMessage: jest.fn().mockRejectedValue(ledgerError), + }); + const signer = makeSigner(service); + + await expect(signer.signMessage('hello')).rejects.toBe(ledgerError); + }); +}); diff --git a/src/data/signers/ledgerSigner.ts b/src/data/signers/ledgerSigner.ts new file mode 100644 index 0000000..923f93a --- /dev/null +++ b/src/data/signers/ledgerSigner.ts @@ -0,0 +1,59 @@ +import { Transaction } from 'casper-js-sdk'; +import { + ICasperLedgerService, + ICasperSigner, + ISignTransactionOptions, + ISignTransactionResponse, +} from '../../domain'; + +export interface ICreateLedgerSignerParams { + service: ICasperLedgerService; + publicKeyHex: string; + derivationIndex?: number; + supportsTransactionV1Cb?: (publicKey: string, supports: boolean) => Promise; + tryToRestoreSessionCb?: () => Promise; +} + +export const createLedgerSigner = ({ + service, + publicKeyHex, + derivationIndex, + supportsTransactionV1Cb, + tryToRestoreSessionCb, +}: ICreateLedgerSignerParams): ICasperSigner => ({ + publicKeyHex, + + async signTransaction(tx: Transaction): Promise { + const resp = await service.signTransaction( + tx, + { index: derivationIndex, publicKey: publicKeyHex }, + supportsTransactionV1Cb, + tryToRestoreSessionCb, + ); + + return { signature: resp.signature, signatureWithPrefix: resp.prefixedSignature }; + }, + + async getSignedTransaction( + tx: Transaction, + options?: ISignTransactionOptions, + ): Promise { + return service.getSignedTransaction( + tx, + { index: derivationIndex, publicKey: publicKeyHex }, + options?.fallbackDeploy ? Transaction.fromDeploy(options.fallbackDeploy) : undefined, + supportsTransactionV1Cb, + tryToRestoreSessionCb, + ); + }, + + async signMessage(message: string): Promise { + const resp = await service.signMessage( + message, + { index: derivationIndex, publicKey: publicKeyHex }, + tryToRestoreSessionCb, + ); + + return resp.signature; + }, +}); diff --git a/src/data/signers/privateKeySigner.test.ts b/src/data/signers/privateKeySigner.test.ts new file mode 100644 index 0000000..7f47a93 --- /dev/null +++ b/src/data/signers/privateKeySigner.test.ts @@ -0,0 +1,94 @@ +import { + Conversions, + KeyAlgorithm, + makeCsprTransferDeploy, + PrivateKey, + Transaction, +} from 'casper-js-sdk'; +import { createPrivateKeySigner } from './privateKeySigner'; +import { isTransactionSignedBy } from '../../utils/transactions'; +import { EmptySignatureError, KeyPairMismatchError } from '../../domain'; + +const TS = '2026-01-01T00:00:00.000Z'; + +const makeFixture = (alg: KeyAlgorithm) => { + const pk = PrivateKey.generate(alg); + const publicKeyHex = pk.publicKey.toHex(); + const secretKeyBase64 = Conversions.encodeBase64(pk.toBytes()); + const tx = Transaction.fromDeploy( + makeCsprTransferDeploy({ + chainName: 'casper-test', + recipientPublicKeyHex: PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(), + senderPublicKeyHex: publicKeyHex, + transferAmount: '2500000000', + timestamp: TS, + }), + ); + return { pk, publicKeyHex, secretKeyBase64, tx }; +}; + +describe.each([KeyAlgorithm.ED25519, KeyAlgorithm.SECP256K1])('createPrivateKeySigner %p', alg => { + it('signTransaction matches direct SDK signing byte-for-byte', async () => { + const { pk, publicKeyHex, secretKeyBase64, tx } = makeFixture(alg); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + const resp = await signer.signTransaction(tx); + expect(Buffer.from(resp.signature)).toEqual(Buffer.from(pk.sign(tx.hash.toBytes()))); + expect(Buffer.from(resp.signatureWithPrefix)).toEqual( + Buffer.from(pk.signAndAddAlgorithmBytes(tx.hash.toBytes())), + ); + }); + + it('getSignedTransaction attaches a verifiable approval', async () => { + const { publicKeyHex, secretKeyBase64, tx } = makeFixture(alg); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + const signed = await signer.getSignedTransaction(tx); + expect(signed.approvals).toHaveLength(1); + expect(isTransactionSignedBy(signed, publicKeyHex)).toBe(true); + // case-insensitive, per isKeysEqual + expect(isTransactionSignedBy(signed, publicKeyHex.toUpperCase())).toBe(true); + }); + + it('getSignedTransaction does not mark the transaction as signed by an unrelated key', async () => { + const { publicKeyHex, secretKeyBase64, tx } = makeFixture(alg); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + const signed = await signer.getSignedTransaction(tx); + const otherPublicKeyHex = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); + expect(isTransactionSignedBy(signed, otherPublicKeyHex)).toBe(false); + }); + + it('signMessage signs raw header-prefixed bytes, unprefixed result', async () => { + const { pk, publicKeyHex, secretKeyBase64 } = makeFixture(alg); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + const sig = await signer.signMessage('msg'); + expect(Buffer.from(sig)).toEqual( + Buffer.from(pk.sign(Uint8Array.from(Buffer.from('Casper Message:\nmsg')))), + ); + }); +}); + +it('throws EmptySignatureError when the SDK returns a falsy signature', async () => { + const { publicKeyHex, secretKeyBase64, tx } = makeFixture(KeyAlgorithm.ED25519); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + jest.spyOn(PrivateKey.prototype, 'sign').mockReturnValueOnce(undefined as never); + await expect(signer.signTransaction(tx)).rejects.toThrow(EmptySignatureError); +}); + +describe('key pair validation', () => { + it('refuses to sign when the public key does not belong to the secret key', async () => { + const { secretKeyBase64, tx } = makeFixture(KeyAlgorithm.ED25519); + const { publicKeyHex } = makeFixture(KeyAlgorithm.ED25519); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + + await expect(signer.signTransaction(tx)).rejects.toThrow(KeyPairMismatchError); + await expect(signer.getSignedTransaction(tx)).rejects.toThrow(KeyPairMismatchError); + await expect(signer.signMessage('msg')).rejects.toThrow(KeyPairMismatchError); + }); + + it('refuses a pair whose algorithms differ', async () => { + const { secretKeyBase64, tx } = makeFixture(KeyAlgorithm.ED25519); + const { publicKeyHex } = makeFixture(KeyAlgorithm.SECP256K1); + const signer = createPrivateKeySigner({ publicKeyHex, secretKeyBase64 }); + + await expect(signer.signTransaction(tx)).rejects.toThrow(); + }); +}); diff --git a/src/data/signers/privateKeySigner.ts b/src/data/signers/privateKeySigner.ts new file mode 100644 index 0000000..615bf61 --- /dev/null +++ b/src/data/signers/privateKeySigner.ts @@ -0,0 +1,86 @@ +import { Conversions, PrivateKey, PublicKey, Transaction } from 'casper-js-sdk'; +import { + EmptySignatureError, + ICasperSigner, + ISignTransactionOptions, + ISignTransactionResponse, + KeyPairMismatchError, +} from '../../domain'; +import { isKeysEqual } from '../../utils/common'; +import { convertBase64ToBytes } from '../../utils/crypto'; +import { createCasperMessageBytes, getPrivateKeyHexFromSecretKey } from '../../utils/transactions'; + +export interface ICreatePrivateKeySignerParams { + publicKeyHex: string; + /** base64 of the raw secret-key bytes — the encoding both wallets store in their vaults. */ + secretKeyBase64: string; +} + +export const createPrivateKeySigner = ({ + publicKeyHex, + secretKeyBase64, +}: ICreatePrivateKeySignerParams): ICasperSigner => { + let cachedPrivateKey: PrivateKey | undefined; + + /** Derived once. Throws {@link KeyPairMismatchError} if the pair does not match. */ + const getPrivateKey = (): PrivateKey => { + if (cachedPrivateKey) { + return cachedPrivateKey; + } + + const publicKey = PublicKey.fromHex(publicKeyHex); + + const privateKey = PrivateKey.fromHex( + getPrivateKeyHexFromSecretKey( + Conversions.encodeBase16(convertBase64ToBytes(secretKeyBase64)), + ), + publicKey.cryptoAlg, + ); + + if (!isKeysEqual(privateKey.publicKey.toHex(), publicKeyHex)) { + throw new KeyPairMismatchError(); + } + + cachedPrivateKey = privateKey; + + return privateKey; + }; + + const sign = (data: Uint8Array, withAlgorithmPrefix: boolean): Uint8Array => { + const privateKey = getPrivateKey(); + const signature = withAlgorithmPrefix + ? privateKey.signAndAddAlgorithmBytes(data) + : privateKey.sign(data); + + if (!signature) { + throw new EmptySignatureError(); + } + + return signature; + }; + + return { + publicKeyHex, + + async signTransaction(tx: Transaction): Promise { + return { + signature: sign(tx.hash.toBytes(), false), + signatureWithPrefix: sign(tx.hash.toBytes(), true), + }; + }, + + // `options.fallbackDeploy` is intentionally ignored: a software key can always sign TransactionV1. + async getSignedTransaction( + tx: Transaction, + _options?: ISignTransactionOptions, + ): Promise { + tx.sign(getPrivateKey()); + + return tx; + }, + + async signMessage(message: string): Promise { + return sign(createCasperMessageBytes(message), false); + }, + }; +}; diff --git a/src/domain/accountInfo/errors.ts b/src/domain/accountInfo/errors.ts index dc28fdf..53fd624 100644 --- a/src/domain/accountInfo/errors.ts +++ b/src/domain/accountInfo/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; import { IAccountInfoRepository } from './repository'; export type AccountInfoErrorType = keyof IAccountInfoRepository; @@ -11,21 +11,11 @@ export function isAccountInfoError(error: unknown | IAccountInfoError): error is ); } -export class AccountInfoError extends Error implements IAccountInfoError { +export class AccountInfoError + extends DomainError + implements IAccountInfoError +{ constructor(error: Error | unknown, type: AccountInfoErrorType) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'AccountInfoRepositoryError'; - this.type = type; + super(error, type, 'AccountInfoRepositoryError'); } - - type: AccountInfoErrorType; - traceable: boolean; } diff --git a/src/domain/appEvents/entities.ts b/src/domain/appEvents/entities.ts index 0a4ac18..898fd6d 100644 --- a/src/domain/appEvents/entities.ts +++ b/src/domain/appEvents/entities.ts @@ -14,5 +14,5 @@ export interface IAppMarketingEvent { readonly endAt: Maybe; readonly startAt: string; readonly url: string; - readonly image_url: Maybe; + readonly imageUrl: Maybe; } diff --git a/src/domain/appEvents/errors.ts b/src/domain/appEvents/errors.ts index c49c314..c14e3e9 100644 --- a/src/domain/appEvents/errors.ts +++ b/src/domain/appEvents/errors.ts @@ -1,5 +1,5 @@ import { IAppEventsRepository } from './repository'; -import { isDomainError, isError } from '../common'; +import { DomainError } from '../common'; import { IDomainError } from '../common'; export type AppEventsErrorType = keyof IAppEventsRepository; @@ -11,21 +11,8 @@ export function isAppEventsError(error: unknown | AppEventsError): error is IApp ); } -export class AppEventsError extends Error implements IAppEventsError { +export class AppEventsError extends DomainError implements IAppEventsError { constructor(error: Error | unknown, type: keyof IAppEventsRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'AppEventsRepositoryError'; - this.type = type; + super(error, type, 'AppEventsRepositoryError'); } - - type: AppEventsErrorType; - traceable: boolean; } diff --git a/src/domain/casperTransactions/entities.ts b/src/domain/casperTransactions/entities.ts new file mode 100644 index 0000000..fd94a48 --- /dev/null +++ b/src/domain/casperTransactions/entities.ts @@ -0,0 +1,107 @@ +// type-only: a value import would pull the sdk into the domain layer +import type { Deploy, Transaction } from 'casper-js-sdk'; +import type { CasperNetwork } from '../common'; +import type { AuctionManagerEntryPointType } from '../constants'; +import type { IBuiltDexTransaction } from '../dex'; +import type { IToken } from '../tokens'; +import type { INft } from '../nfts'; +import type { Maybe } from '../../typings'; + +/** Raw + algorithm-prefixed signature over a transaction hash or message. */ +export interface ISignTransactionResponse { + signature: Uint8Array; + signatureWithPrefix: Uint8Array; +} + +export interface ISignTransactionOptions { + /** Legacy Deploy equivalent for signers that cannot sign TransactionV1 (old Ledger apps). */ + fallbackDeploy?: Deploy; +} + +/** + * The only thing an app must supply to sign: either a private-key signer built by + * `createPrivateKeySigner`, or a hardware signer (`createLedgerSigner` / app adapter). + */ +export interface ICasperSigner { + readonly publicKeyHex: string; + /** Raw signature pair over the transaction (dApp signature-request flows). Does not mutate `tx`. */ + signTransaction(tx: Transaction): Promise; + /** Sign and attach an approval. May resolve with a different object than `tx` when the legacy fallback was signed. */ + getSignedTransaction(tx: Transaction, options?: ISignTransactionOptions): Promise; + /** Raw signature over `CASPER_MESSAGE_HEADER + message` bytes. */ + signMessage(message: string): Promise; +} + +export interface ICasperRpcOptions { + /** casper-js-sdk HttpHandler flavor. Default 'fetch'. Mobile passes 'axios'. */ + handlerType?: 'fetch' | 'axios'; + /** + * How the CSPR.cloud proxy allowlist referrer is attached. + * 'fetch-referrer' — HttpHandler.setReferrer (browsers; forbidden to set the header directly). + * 'referer-header' — literal `Referer` custom header (React Native; fetch ignores the referrer init). + * Default 'fetch-referrer'. + */ + referrerMode?: 'fetch-referrer' | 'referer-header'; + authorizationHeader?: string; +} + +interface ISendParamsBase { + network: CasperNetwork; + /** Node API version string from `getNetworkApiVersion` (e.g. '1.5.8', '2.0.0'). */ + casperNetworkApiVersion: string; + signer: ICasperSigner; +} + +export interface ISendTokenTransferParams extends ISendParamsBase { + token: IToken; + toPublicKeyHex: string; + /** Human-decimal token amount (converted with `token.decimals`). */ + amount: string; + /** Human-decimal CSPR payment (ignored for native CSPR transfers). */ + paymentAmount: string; + memo?: Maybe; +} + +export interface ISendNftTransferParams extends ISendParamsBase { + nft: INft; + toPublicKeyHex: string; + /** Human-decimal CSPR payment. */ + paymentAmount: string; +} + +export interface ISendDelegationParams extends ISendParamsBase { + entryPoint: AuctionManagerEntryPointType; + /** Human-decimal CSPR stake. */ + stake: string; + /** Human-decimal CSPR payment. */ + paymentAmount: string; + validatorPublicKeyHex: string; + newValidatorPublicKeyHex?: string; +} + +export interface ISignTransactionParams { + transaction: Transaction; + signer: ICasperSigner; +} + +export interface ISignMessageParams { + message: string; + signer: ICasperSigner; +} + +export interface ISendSignedTransactionParams { + transaction: Transaction; + network: CasperNetwork; + casperNetworkApiVersion: string; +} + +/** + * Sign + submit one built DEX artifact (swap / wrap / unwrap / approve). The artifact kind is + * fixed at build time via `useTransactionV1`: a `deploy` submits via `putDeploy`, a + * `transaction` via `putTransaction`, regardless of the node's API version. + */ +export interface ISendDexTransactionParams { + built: IBuiltDexTransaction; + network: CasperNetwork; + signer: ICasperSigner; +} diff --git a/src/domain/casperTransactions/errors.test.ts b/src/domain/casperTransactions/errors.test.ts new file mode 100644 index 0000000..f5520e7 --- /dev/null +++ b/src/domain/casperTransactions/errors.test.ts @@ -0,0 +1,66 @@ +import { + AlreadySignedError, + CasperTransactionsError, + EmptySignatureError, + isCasperTransactionsError, + KeyPairMismatchError, +} from './errors'; + +describe('CasperTransactionsError', () => { + it('wraps an Error preserving message and stack, traceable by default', () => { + const inner = new Error('boom'); + const err = new CasperTransactionsError(inner, 'signMessage'); + expect(err.message).toBe('boom'); + expect(err.type).toBe('signMessage'); + expect(err.name).toBe('CasperTransactionsError'); + expect(err.traceable).toBe(true); + expect(err.stack).toBe(inner.stack); + }); + + it('stringifies non-Error input', () => { + const err = new CasperTransactionsError({ a: 1 }, 'sendTokenTransfer'); + expect(err.message).toBe('{"a":1}'); + expect(err.traceable).toBe(true); + }); + + it('propagates traceable=false from domain errors', () => { + const inner = Object.assign(new Error('x'), { type: 't', traceable: false }); + const err = new CasperTransactionsError(inner, 'signTransaction'); + expect(err.traceable).toBe(false); + }); + + it('guard matches only CasperTransactionsError', () => { + expect( + isCasperTransactionsError(new CasperTransactionsError(new Error('x'), 'signature')), + ).toBe(true); + expect(isCasperTransactionsError(new Error('x'))).toBe(false); + expect(isCasperTransactionsError(null)).toBe(false); + }); + + it('subclasses carry mobile-compatible message keys', () => { + expect(new AlreadySignedError().message).toBe('errors:already-signed'); + expect(new EmptySignatureError().message).toBe('errors:empty-signature'); + expect(new AlreadySignedError().type).toBe('signature'); + }); + + it('keeps the source error by reference', () => { + const inner = new Error('boom'); + + expect(new CasperTransactionsError(inner, 'sendSignedTransaction').sourceError).toBe(inner); + }); + + it('keeps type and traceable defined on every subclass', () => { + for (const err of [ + new AlreadySignedError(), + new EmptySignatureError(), + new KeyPairMismatchError(), + ]) { + expect(err.type).toBe('signature'); + expect(err.traceable).toBeDefined(); + } + }); + + it('subclasses keep their exact message keys', () => { + expect(new KeyPairMismatchError().message).toBe('errors:key-pair-mismatch'); + }); +}); diff --git a/src/domain/casperTransactions/errors.ts b/src/domain/casperTransactions/errors.ts new file mode 100644 index 0000000..eefb607 --- /dev/null +++ b/src/domain/casperTransactions/errors.ts @@ -0,0 +1,44 @@ +import { DomainError, IDomainError } from '../common'; +import type { ICasperTransactionsRepository } from './repository'; + +export type CasperTransactionsErrorType = keyof ICasperTransactionsRepository | 'signature'; + +export type ICasperTransactionsError = IDomainError; + +export class CasperTransactionsError + extends DomainError + implements ICasperTransactionsError +{ + constructor(error: Error | unknown, type: CasperTransactionsErrorType) { + super(error, type, 'CasperTransactionsError'); + } +} + +export function isCasperTransactionsError( + error: unknown | ICasperTransactionsError, +): error is ICasperTransactionsError { + return error instanceof CasperTransactionsError && error.name === 'CasperTransactionsError'; +} + +export class AlreadySignedError extends CasperTransactionsError { + constructor() { + super(new Error('errors:already-signed'), 'signature'); + } +} + +export class EmptySignatureError extends CasperTransactionsError { + constructor() { + super(new Error('errors:empty-signature'), 'signature'); + } +} + +/** + * The supplied `publicKeyHex` does not belong to the supplied secret key. Raised before signing: + * the curve is taken from the public key, so a mismatched pair would be caught only by the node, + * after the payment is committed. + */ +export class KeyPairMismatchError extends CasperTransactionsError { + constructor() { + super(new Error('errors:key-pair-mismatch'), 'signature'); + } +} diff --git a/src/domain/casperTransactions/index.ts b/src/domain/casperTransactions/index.ts new file mode 100644 index 0000000..379f362 --- /dev/null +++ b/src/domain/casperTransactions/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './repository'; +export * from './errors'; diff --git a/src/domain/casperTransactions/repository.ts b/src/domain/casperTransactions/repository.ts new file mode 100644 index 0000000..0e5cb95 --- /dev/null +++ b/src/domain/casperTransactions/repository.ts @@ -0,0 +1,29 @@ +import type { + ISendDelegationParams, + ISendDexTransactionParams, + ISendNftTransferParams, + ISendSignedTransactionParams, + ISendTokenTransferParams, + ISignMessageParams, + ISignTransactionParams, + ISignTransactionResponse, +} from './entities'; +import type { CasperNetwork } from '../common'; + +export interface ICasperTransactionsRepository { + /** `getStatus().apiVersion` from the network's node. Apps cache it (redux) and pass it back into send/build calls. */ + getNetworkApiVersion(network: CasperNetwork): Promise; + /** Drift-corrected ISO timestamp for building transactions (node time vs local−2s, whichever is later). */ + getDateForTransaction(network: CasperNetwork): Promise; + /** Build + sign + submit. Resolves with the deploy/transaction hash hex. */ + sendTokenTransfer(params: ISendTokenTransferParams): Promise; + sendNftTransfer(params: ISendNftTransferParams): Promise; + sendDelegation(params: ISendDelegationParams): Promise; + /** Submit an externally signed transaction (granular flow — extension Ledger window, WalletConnect). */ + sendSignedTransaction(params: ISendSignedTransactionParams): Promise; + /** Sign + submit a built DEX artifact (`IBuiltDexTransaction`). Resolves with the deploy/transaction hash hex. */ + sendDexTransaction(params: ISendDexTransactionParams): Promise; + /** Raw signature pair for dApp signature requests. Throws AlreadySignedError if this key already approved. */ + signTransaction(params: ISignTransactionParams): Promise; + signMessage(params: ISignMessageParams): Promise; +} diff --git a/src/domain/common/error-keys.test.ts b/src/domain/common/error-keys.test.ts new file mode 100644 index 0000000..4c27117 --- /dev/null +++ b/src/domain/common/error-keys.test.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; + +import { + CORE_ERROR_MESSAGE_KEYS as ROOT_CORE_ERROR_MESSAGE_KEYS, + CoreErrorMessageKey, +} from '../../../index'; + +import { CORE_ERROR_MESSAGE_KEYS } from './error-keys'; + +const SRC_ROOT = path.resolve(__dirname, '../..'); +const KEY_PATTERN = /'(errors:[a-z0-9-]+)'/g; +// The catalogue lists every key as a literal, so scanning it would make the stray case vacuous. +const CATALOGUE_FILE = path.join(SRC_ROOT, 'domain', 'common', 'error-keys.ts'); + +const walk = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return walk(full); + } + + return entry.name.endsWith('.ts') && !entry.name.includes('.test.') && full !== CATALOGUE_FILE + ? [full] + : []; + }); + +const literalsInSource = (): Map => { + const found = new Map(); + + for (const file of walk(SRC_ROOT)) { + const source = fs.readFileSync(file, 'utf8'); + + for (const [, key] of source.matchAll(KEY_PATTERN)) { + if (!found.has(key)) { + found.set(key, path.relative(SRC_ROOT, file)); + } + } + } + + return found; +}; + +describe('CORE_ERROR_MESSAGE_KEYS', () => { + it('lists every errors:* literal in the source', () => { + const missing = [...literalsInSource()] + .filter(([key]) => !CORE_ERROR_MESSAGE_KEYS.includes(key as never)) + .map(([key, file]) => `${key} (${file})`); + + expect(missing).toEqual([]); + }); + + it('lists nothing that the source does not use', () => { + const found = literalsInSource(); + // The flow keys are built by template and never appear as literals; assert them separately. + const templated = ['errors:flow-runner-account-mismatch']; + const strays = CORE_ERROR_MESSAGE_KEYS.filter( + key => !found.has(key) && !templated.includes(key), + ); + + expect(strays).toEqual([]); + }); + + it('is exported from the package root as a literal union', () => { + expect(ROOT_CORE_ERROR_MESSAGE_KEYS).toBe(CORE_ERROR_MESSAGE_KEYS); + expect(ROOT_CORE_ERROR_MESSAGE_KEYS.length).toBeGreaterThan(0); + + const member: CoreErrorMessageKey = 'errors:unexpected'; + // @ts-expect-error a widened `string` here would stop consumers' copy maps being checkable + const nonMember: CoreErrorMessageKey = 'not-a-core-key'; + + expect(ROOT_CORE_ERROR_MESSAGE_KEYS).toContain(member); + expect(ROOT_CORE_ERROR_MESSAGE_KEYS).not.toContain(nonMember); + }); + + it('covers every FlowErrorType expansion', () => { + const flowTypes = ['runner-account-mismatch']; + + for (const type of flowTypes) { + expect(CORE_ERROR_MESSAGE_KEYS).toContain(`errors:flow-${type}`); + } + }); +}); diff --git a/src/domain/common/error-keys.ts b/src/domain/common/error-keys.ts new file mode 100644 index 0000000..54d356e --- /dev/null +++ b/src/domain/common/error-keys.ts @@ -0,0 +1,38 @@ +/** + * Every i18n key core can put in an error's `message`. Core ships no copy: a consumer maps these + * to its own strings, and {@link CoreErrorMessageKey} makes an incomplete map a type error. + * `error-keys.test.ts` scans `src/` to keep the list exhaustive. + */ +export const CORE_ERROR_MESSAGE_KEYS = [ + 'errors:already-signed', + 'errors:cancel-request-error', + 'errors:client-validation', + 'errors:connection-error', + 'errors:deploy-rpc-error', + 'errors:empty-signature', + 'errors:flow-runner-account-mismatch', + 'errors:forbidden', + 'errors:invalid-deploy', + 'errors:invalid-signature-request', + 'errors:invalid-transaction-json-error', + 'errors:key-pair-mismatch', + 'errors:network-error', + 'errors:not-found', + 'errors:server-500-error', + 'errors:server-502-error', + 'errors:server-503-error', + 'errors:server-504-error', + 'errors:server-506-error', + 'errors:server-507-error', + 'errors:server-508-error', + 'errors:server-510-error', + 'errors:server-error', + 'errors:timeout-error', + 'errors:transaction-settlement-timeout', + 'errors:transaction-watch-cancelled', + 'errors:unauthorized', + 'errors:unexpected', + 'errors:unexpected-http', +] as const; + +export type CoreErrorMessageKey = (typeof CORE_ERROR_MESSAGE_KEYS)[number]; diff --git a/src/domain/common/errors.test.ts b/src/domain/common/errors.test.ts new file mode 100644 index 0000000..70b9044 --- /dev/null +++ b/src/domain/common/errors.test.ts @@ -0,0 +1,164 @@ +import { DomainError, getNodeErrorDetails, isDomainError } from './errors'; + +class TestError extends DomainError<'test'> { + constructor(error: unknown) { + super(error, 'test', 'TestError'); + } +} + +class QuietError extends DomainError<'test'> { + constructor(error: unknown, traceable = false) { + super(error, 'test', 'QuietError', traceable); + } +} + +describe('DomainError', () => { + it('wraps an Error preserving message, stack and traceable', () => { + const inner = new Error('boom'); + const err = new TestError(inner); + + expect(err.message).toBe('boom'); + expect(err.stack).toBe(inner.stack); + expect(err.traceable).toBe(true); + expect(err.name).toBe('TestError'); + expect(err.type).toBe('test'); + }); + + it('propagates traceable=false from a wrapped domain error', () => { + const inner = Object.assign(new Error('x'), { type: 't', traceable: false }); + + expect(new TestError(inner).traceable).toBe(false); + }); + + it('stringifies a non-Error and a string', () => { + expect(new TestError({ a: 1 }).message).toBe('{"a":1}'); + expect(new TestError('plain failure').message).toBe('"plain failure"'); + expect(new TestError({ a: 1 }).traceable).toBe(true); + }); + + it('keeps the source error by reference, for both branches', () => { + const inner = new Error('boom'); + const raw = { a: 1 }; + + expect(new TestError(inner).sourceError).toBe(inner); + expect(new TestError(raw).sourceError).toBe(raw); + }); + + it('keeps sourceError out of enumeration and serialization', () => { + const err = new TestError(new Error('boom')); + + expect(Object.keys(err)).not.toContain('sourceError'); + expect(JSON.stringify(err)).not.toContain('sourceError'); + expect('sourceError' in err).toBe(true); + expect(Object.getOwnPropertyDescriptor(err, 'sourceError')?.enumerable).toBe(false); + }); + + it('does not populate the standard cause property', () => { + expect(new TestError(new Error('boom')).cause).toBeUndefined(); + }); + + it('is a real Error and a domain error', () => { + const err = new TestError(new Error('boom')); + + expect(err).toBeInstanceOf(DomainError); + expect(err).toBeInstanceOf(Error); + expect(isDomainError(err)).toBe(true); + }); + + it('nests', () => { + const root = new Error('root'); + const inner = new TestError(root); + const outer = new TestError(inner); + + expect(outer.sourceError).toBe(inner); + expect((outer.sourceError as TestError).sourceError).toBe(root); + }); + + it('applies the traceable argument on both wrapping branches', () => { + expect(new QuietError(new Error('boom')).traceable).toBe(false); + expect(new QuietError({ a: 1 }).traceable).toBe(false); + expect(new QuietError('plain failure').traceable).toBe(false); + expect(new QuietError(new Error('boom'), true).traceable).toBe(true); + }); + + it('lets a wrapped domain error outrank the traceable argument', () => { + const silenced = new QuietError(new Error('boom')); + const noisy = new TestError(new Error('boom')); + + expect(new QuietError(silenced, true).traceable).toBe(false); + expect(new QuietError(noisy).traceable).toBe(true); + }); +}); + +const rpcError = (over: Partial<{ code: number; message: string; data: unknown }> = {}) => + Object.assign(new Error(over.message ?? 'invalid deploy'), { + code: over.code ?? -32008, + data: 'data' in over ? over.data : 'bad hash', + }); + +const transportError = (sourceErr: Error, statusCode = 500) => + Object.assign(new Error(`Code: ${statusCode}, err: ${sourceErr.message}`), { + statusCode, + sourceErr, + }); + +describe('getNodeErrorDetails', () => { + it('reads a wrapped transport + JSON-RPC failure', () => { + const inner = transportError(rpcError()); + + expect(getNodeErrorDetails(new TestError(inner))).toEqual({ + message: 'invalid deploy', + code: -32008, + statusCode: 500, + data: 'bad hash', + }); + }); + + it('reads a bare transport error', () => { + expect(getNodeErrorDetails(transportError(new Error('gateway'), 502))).toEqual({ + message: 'gateway', + statusCode: 502, + }); + }); + + it('reads a bare JSON-RPC error', () => { + expect(getNodeErrorDetails(rpcError())).toEqual({ + message: 'invalid deploy', + code: -32008, + data: 'bad hash', + }); + }); + + it('finds detail through nested domain errors', () => { + const inner = transportError(rpcError({ message: 'deep' })); + + expect(getNodeErrorDetails(new TestError(new TestError(inner)))?.message).toBe('deep'); + }); + + it('returns null when there is no node detail', () => { + expect(getNodeErrorDetails(new Error('boom'))).toBeNull(); + expect(getNodeErrorDetails(new TestError(new Error('boom')))).toBeNull(); + expect(getNodeErrorDetails(null)).toBeNull(); + expect(getNodeErrorDetails(undefined)).toBeNull(); + expect(getNodeErrorDetails('string')).toBeNull(); + expect(getNodeErrorDetails({})).toBeNull(); + }); + + it('returns null for a fixed-message domain error', () => { + expect(getNodeErrorDetails(new TestError(new Error('errors:key-pair-mismatch')))).toBeNull(); + }); + + it('passes structured data through untouched', () => { + const data = { reason: 'bad hash' }; + + expect(getNodeErrorDetails(rpcError({ data }))?.data).toBe(data); + }); + + it('terminates on a cyclic chain', () => { + const err = new Error('loop') as Error & { sourceErr?: unknown; statusCode?: number }; + err.sourceErr = err; + err.statusCode = 500; + + expect(() => getNodeErrorDetails(err)).not.toThrow(); + }); +}); diff --git a/src/domain/common/errors.ts b/src/domain/common/errors.ts index 23a3530..7ef2665 100644 --- a/src/domain/common/errors.ts +++ b/src/domain/common/errors.ts @@ -10,3 +10,110 @@ export function isError(error: unknown | Error): error is Error { export function isDomainError(err: unknown | IDomainError): err is IDomainError { return err instanceof Error && (err).type !== undefined; } + +export abstract class DomainError extends Error implements IDomainError { + /** + * The error this one was built from, verbatim — whatever was thrown. Read it to reach the + * transport or node detail the wrapper's `message` cannot carry; {@link getNodeErrorDetails} + * does exactly that. + */ + declare readonly sourceError: unknown; + + type: T; + traceable: boolean; + + /** + * `traceable` says whether a consumer should report this to its crash reporter. It is only a + * default: wrapping a domain error always keeps that error's flag, so a subclass cannot make an + * already-silenced failure noisy again. + */ + protected constructor(error: Error | unknown, type: T, name: string, traceable = true) { + if (isError(error)) { + super(error.message); + this.stack = error.stack; + this.traceable = isDomainError(error) ? Boolean(error.traceable) : traceable; + } else { + super(JSON.stringify(error)); + this.traceable = traceable; + } + + this.name = name; + this.type = type; + + // Non-enumerable and deliberately not `cause`: both are what keep Sentry from chaining the + // RPC payload — account hashes, full deploy JSON — into events. + Object.defineProperty(this, 'sourceError', { + value: error, + enumerable: false, + writable: false, + configurable: true, + }); + } +} + +export interface INodeErrorDetails { + /** What the node said, verbatim. Never translated, never prefixed. */ + message: string; + /** JSON-RPC error code, when the failure carried one. */ + code?: number; + /** HTTP status, when the transport reported one. */ + statusCode?: number; + /** Whatever the node attached to the JSON-RPC error — shape is the node's business. */ + data?: unknown; +} + +const MAX_CHAIN_DEPTH = 16; + +/** + * Pulls node-provided detail out of an error thrown by the transaction layer, walking the + * `sourceError` chain and matching casper-js-sdk's transport (`statusCode` + `sourceErr`) and + * JSON-RPC (`code` + `data`) shapes structurally. Returns `null` when the failure carries no + * node detail; callers render their own copy in that case. + */ +export const getNodeErrorDetails = (error: unknown): INodeErrorDetails | null => { + const seen = new Set(); + let current: unknown = error; + let statusCode: number | undefined; + + for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth += 1) { + if (!current || typeof current !== 'object' || seen.has(current)) { + return null; + } + + seen.add(current); + + const candidate = current as { + message?: unknown; + code?: unknown; + data?: unknown; + statusCode?: unknown; + sourceErr?: unknown; + sourceError?: unknown; + }; + + if (typeof candidate.statusCode === 'number') { + statusCode = candidate.statusCode; + } + + if (typeof candidate.code === 'number' && typeof candidate.message === 'string') { + return { + message: candidate.message, + code: candidate.code, + ...(statusCode === undefined ? {} : { statusCode }), + ...(candidate.data === undefined ? {} : { data: candidate.data }), + }; + } + + const next = candidate.sourceErr ?? candidate.sourceError; + + if (next === undefined) { + return statusCode !== undefined && typeof candidate.message === 'string' + ? { message: candidate.message, statusCode } + : null; + } + + current = next; + } + + return null; +}; diff --git a/src/domain/common/http/data-provider.ts b/src/domain/common/http/data-provider.ts index 0ed4c50..2eb01e7 100644 --- a/src/domain/common/http/data-provider.ts +++ b/src/domain/common/http/data-provider.ts @@ -12,6 +12,8 @@ import type { AppEventsErrorType, TxSignatureRequestErrorType, ContractPackageErrorType, + SwapErrorType, + DexErrorType, } from '../../../domain'; export interface IHttpDataProvider { @@ -38,7 +40,9 @@ export type IHttpErrorType = | AccountInfoErrorType | AppEventsErrorType | TxSignatureRequestErrorType - | ContractPackageErrorType; + | ContractPackageErrorType + | SwapErrorType + | DexErrorType; export interface IHttpMethodBaseParams { url: string; diff --git a/src/domain/common/index.ts b/src/domain/common/index.ts index e98a510..dc4dcf3 100644 --- a/src/domain/common/index.ts +++ b/src/domain/common/index.ts @@ -1,5 +1,6 @@ export * from './logger'; export * from './common'; +export * from './error-keys'; export * from './errors'; export * from './http/data-provider'; export * from './http/errors'; diff --git a/src/domain/constants/casperNetwork.test.ts b/src/domain/constants/casperNetwork.test.ts new file mode 100644 index 0000000..4cafdd2 --- /dev/null +++ b/src/domain/constants/casperNetwork.test.ts @@ -0,0 +1,8 @@ +import { CASPER_MESSAGE_HEADER, CSPR_COIN_INDEX } from './casperNetwork'; + +describe('casperTransactions constants', () => { + it('message header and coin index are exact', () => { + expect(CASPER_MESSAGE_HEADER).toBe('Casper Message:\n'); + expect(CSPR_COIN_INDEX).toBe(506); + }); +}); diff --git a/src/domain/constants/casperNetwork.ts b/src/domain/constants/casperNetwork.ts index 96509bd..463c6ed 100644 --- a/src/domain/constants/casperNetwork.ts +++ b/src/domain/constants/casperNetwork.ts @@ -31,6 +31,13 @@ export const CasperWalletApiByEnvUrl: Record = { export const OnRampApiUrl = 'https://onramp-api.cspr.click/api'; +export const TradeApiUrl: Record = { + mainnet: 'https://api.cspr.trade', + testnet: 'https://api.testnet.cspr.trade', + devnet: '', + integration: '', +}; + export const GrpcUrl: Record = { mainnet: 'https://node.cspr.cloud/rpc', testnet: 'https://node.testnet.cspr.cloud/rpc', @@ -80,6 +87,21 @@ export const AssociatedKeysContractHash: Record = { integration: '', }; +// Package hashes without the `hash-` prefix. +export const TradeContractPackageHash: Record = { + mainnet: '1dbac65585475fec53e5b1f9110923c8d232921702097e83105b36751d682186', + testnet: '04a11a367e708c52557930c4e9c1301f4465100d1b1b6d0a62b48d3e32402867', + devnet: '', + integration: '', +}; + +export const WrappedCsprContractPackageHash: Record = { + mainnet: '8df5d26790e18cf0404502c62ce5dc9025800ad6975c97466e20506c39c505b6', + testnet: '3d80df21ba4ee4d66a2a1f60c32570dd5685e4b279f6538162a5fd1314847c1e', + devnet: '', + integration: '', +}; + export const ExecutionTypesMap: Record = { 1: 'wasmDeploy', //"ModuleBytes" 2: 'contractCall', //"StoredContractByHash" @@ -278,3 +300,11 @@ export const NFT_ACTION_ENTRY_POINTS = [ 'update_token_meta', 'set_approval_for_all', ]; + +export const CASPER_MESSAGE_HEADER = 'Casper Message:\n'; + +/** Registered coin type for BIP-0044. https://github.com/satoshilabs/slips/blob/master/slip-0044.md */ +export const CSPR_COIN_INDEX = 506; + +/** Keys of the auction-manager entry-point map (the SDK-enum map itself lives in `src/utils/casperSdk/tx-builders.ts`). */ +export type AuctionManagerEntryPointType = 'DELEGATE' | 'UNDELEGATE' | 'REDELEGATE'; diff --git a/src/domain/constants/common.ts b/src/domain/constants/common.ts index 735fce2..0bc1911 100644 --- a/src/domain/constants/common.ts +++ b/src/domain/constants/common.ts @@ -1,4 +1,4 @@ -import { PaginatedResponse } from '../common'; +import { PaginatedResponse, SupportedFiatCurrencies } from '../common'; export const EMPTY_PAGINATED_RESPONSE: PaginatedResponse = { data: [], @@ -6,3 +6,10 @@ export const EMPTY_PAGINATED_RESPONSE: PaginatedResponse = { pageCount: 0, pages: [], }; + +/** + * The library reports one fiat currency (see `SupportedFiatCurrencies`); these pin the two + * representations of it — the trade API's numeric currency id and the ISO code used for display. + */ +export const USD_CURRENCY_ID = 1; +export const USD_CURRENCY_CODE: SupportedFiatCurrencies = 'USD'; diff --git a/src/domain/constants/config.ts b/src/domain/constants/config.ts index 60d0c04..c2ef103 100644 --- a/src/domain/constants/config.ts +++ b/src/domain/constants/config.ts @@ -9,3 +9,37 @@ export const IMAGE_WIDTH = 376; export const CACHE_TTL = '2592000'; export const CSPR_API_PROXY_HEADERS = { Referer: 'https://casperwallet.io' }; + +export const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000'; + +/** + * Synthetic native-token id for the swap domain: the trade API has no record for native CSPR, + * so the token list maps the WCSPR record onto this id and `token.id === CSPR_NATIVE_TOKEN_ID` + * becomes the native-leg check. Distinct from `CSPR_COIN.id`, which identifies the coin in the + * wallet's own balance list. + */ +export const CSPR_NATIVE_TOKEN_ID = 'cspr'; + +export const DEFAULT_SLIPPAGE = 3; +export const MIN_SLIPPAGE = 0.01; +export const MAX_SLIPPAGE = 50; +export const DEFAULT_DEADLINE = 20; // minutes +export const MIN_DEADLINE = 1; +export const MAX_DEADLINE = 120; + +// Motes, as strings. +export const DEX_PAYMENT_AMOUNT = { + approve: '5000000000', // 5 CSPR + swapCsprForToken: '30000000000', // 30 CSPR + swapTokenForToken: '30000000000', // 30 CSPR + wrap: '5000000000', // 5 CSPR + unwrap: '5000000000', // 5 CSPR +} as const; + +export const DEX_TRANSACTION_TTL_MS = 1800000; +export const SWAP_PROTOCOL_FEE = 0.003; +export const SWAP_PRICE_IMPACT_WARNING_THRESHOLD = 10; // % +export const HIGH_SLIPPAGE_WARNING_THRESHOLD = 10; // % +export const BLOCK_INTERVAL_MS = 8000; +export const POSSIBLE_QUOTE_LATENCY_MS = 500; +export const NO_FIAT_RATE_LABEL = 'N/A'; diff --git a/src/domain/contractPackage/errors.ts b/src/domain/contractPackage/errors.ts index 880ce23..f5ed041 100644 --- a/src/domain/contractPackage/errors.ts +++ b/src/domain/contractPackage/errors.ts @@ -1,5 +1,5 @@ import { IContractPackageRepository } from './repository'; -import { isDomainError, isError } from '../common'; +import { DomainError } from '../common'; import { IDomainError } from '../common'; export type ContractPackageErrorType = keyof IContractPackageRepository; @@ -14,21 +14,11 @@ export function isContractPackageError( ); } -export class ContractPackageError extends Error implements IContractPackageError { +export class ContractPackageError + extends DomainError + implements IContractPackageError +{ constructor(error: Error | unknown, type: keyof IContractPackageRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'ContractPackageRepositoryError'; - this.type = type; + super(error, type, 'ContractPackageRepositoryError'); } - - type: ContractPackageErrorType; - traceable: boolean; } diff --git a/src/domain/deploys/errors.test.ts b/src/domain/deploys/errors.test.ts new file mode 100644 index 0000000..b672a67 --- /dev/null +++ b/src/domain/deploys/errors.test.ts @@ -0,0 +1,23 @@ +import { DeploysError, InvalidDeployError, isDeploysError } from './errors'; + +describe('DeploysError', () => { + it('keeps name, type, message, stack and the source error', () => { + const inner = new Error('rpc exploded'); + const err = new DeploysError(inner, 'invalidDeploy'); + + expect(err.name).toBe('DeploysRepositoryError'); + expect(err.type).toBe('invalidDeploy'); + expect(err.message).toBe('rpc exploded'); + expect(err.stack).toBe(inner.stack); + expect(err.sourceError).toBe(inner); + expect(isDeploysError(err)).toBe(true); + }); + + it('keeps InvalidDeployError message keys', () => { + expect(new InvalidDeployError().message).toBe('errors:invalid-deploy'); + expect(new InvalidDeployError('errors:deploy-rpc-error').message).toBe( + 'errors:deploy-rpc-error', + ); + expect(new InvalidDeployError().type).toBe('invalidDeploy'); + }); +}); diff --git a/src/domain/deploys/errors.ts b/src/domain/deploys/errors.ts index 2b9d22a..aa16273 100644 --- a/src/domain/deploys/errors.ts +++ b/src/domain/deploys/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; import { IDeploysRepository } from './repository'; export type DeploysErrorType = keyof IDeploysRepository | 'deployRpcError' | 'invalidDeploy'; @@ -8,23 +8,10 @@ export function isDeploysError(error: unknown | IDeployError): error is IDeployE return error instanceof DeploysError && (error).name === 'DeploysRepositoryError'; } -export class DeploysError extends Error implements IDeployError { +export class DeploysError extends DomainError implements IDeployError { constructor(error: Error | unknown, type: DeploysErrorType) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'DeploysRepositoryError'; - this.type = type; + super(error, type, 'DeploysRepositoryError'); } - - type: DeploysErrorType; - traceable: boolean; } export class InvalidDeployError extends DeploysError { diff --git a/src/domain/dex/entities.ts b/src/domain/dex/entities.ts new file mode 100644 index 0000000..550205c --- /dev/null +++ b/src/domain/dex/entities.ts @@ -0,0 +1,45 @@ +import type { Deploy, Transaction } from 'casper-js-sdk'; // type-only — the sdk-free gate checks value imports + +import type { CasperNetwork } from '../common/common'; + +export type DexTransactionKind = 'approve' | 'swap' | 'wrap' | 'unwrap'; +export type WrapDirection = Extract; + +interface IBuiltDexTransactionBase { + readonly kind: DexTransactionKind; + readonly entryPoint: string; + readonly paymentMotes: string; +} + +/** + * Exactly one of `transaction` / `deploy` is set, selected by the caller's `useTransactionV1`. + * Narrow with `'transaction' in built` rather than asserting: a signer adapter that writes + * `deploy ?? transaction!` gets no help from the compiler if a builder ever returns neither. + */ +export type IBuiltDexTransaction = IBuiltDexTransactionBase & + ( + | { readonly transaction: Transaction; readonly deploy?: never } + | { readonly deploy: Deploy; readonly transaction?: never } + ); + +/** The wrapped-CSPR hash is not here: it is a parameter of the setup factories, shared setup-wide. */ +export interface IDexConfig { + tradeContractPackageHash?: Record; // default TradeContractPackageHash + gasPriceTolerance?: number; // default 1 + /** + * Loader for the `proxy_caller.wasm` bytes. Required: the swap, wrap and unwrap builders + * cannot construct a transaction without it, so a `dexConfig` that omits it produces a + * repository whose only working method is `buildApprovalTransaction`. + */ + getProxyWasm: () => Promise; + /** + * Hex sha256 of the expected `proxy_caller.wasm`, `0x`-prefixed or not (`shasum -a 256 + * proxy_caller.wasm`). When set, the loaded bytes are verified once and the build is refused on + * a mismatch. + * + * Strongly recommended: the bytes run as session code in the caller's account context with + * access to their main purse, and both the wallet UI and the Ledger prompt show only + * "ModuleBytes". + */ + expectedProxyWasmSha256?: string; +} diff --git a/src/domain/dex/errors.ts b/src/domain/dex/errors.ts new file mode 100644 index 0000000..dfc1177 --- /dev/null +++ b/src/domain/dex/errors.ts @@ -0,0 +1,15 @@ +import { DomainError, IDomainError } from '../common'; +import { IDexContractRepository } from './repository'; + +export type DexErrorType = keyof IDexContractRepository; +export type IDexError = IDomainError; + +export function isDexError(error: unknown | IDexError): error is IDexError { + return error instanceof DexError && (error).name === 'DexRepositoryError'; +} + +export class DexError extends DomainError implements IDexError { + constructor(error: Error | unknown, type: DexErrorType) { + super(error, type, 'DexRepositoryError'); + } +} diff --git a/src/domain/dex/index.ts b/src/domain/dex/index.ts new file mode 100644 index 0000000..23b69b2 --- /dev/null +++ b/src/domain/dex/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './errors'; +export * from './repository'; diff --git a/src/domain/dex/repository.ts b/src/domain/dex/repository.ts new file mode 100644 index 0000000..d4ca7c8 --- /dev/null +++ b/src/domain/dex/repository.ts @@ -0,0 +1,69 @@ +import type { IBuiltDexTransaction } from './entities'; + +import type { CasperNetwork } from '../common/common'; +import type { IDexTokenWithAmount, SwapQuoteType } from '../swap'; + +export interface IDexContractRepository { + getAllowance(params: { + network: CasperNetwork; + contractPackageHash: string; + publicKey: string; + operatorContractPackageHash?: string; + }): Promise; + checkApprovalRequired(params: { + network: CasperNetwork; + contractPackageHash: string; + publicKey: string; + requiredAmount: string; + }): Promise; + getLatestBlockTime(params: { network: CasperNetwork }): Promise; + buildApprovalTransaction(params: IBuildApprovalParams): Promise; + /** + * Sets the trade contract's allowance for this token back to zero. + * + * A swap approves a bounded amount and the unspent remainder stays granted afterwards, to a + * contract package whose owner can upgrade the implementation behind it. Nothing revokes + * automatically: this is a further signature and payment, and it forfeits the saving of a + * still-sufficient allowance on the next swap. Read the standing amount with {@link getAllowance}. + */ + buildRevokeApprovalTransaction(params: IBuildRevokeApprovalParams): Promise; + buildSwapTransaction(params: IBuildSwapParams): Promise; + buildWrapTransaction(params: IBuildWrapParams): Promise; + buildUnwrapTransaction(params: IBuildUnwrapParams): Promise; +} + +export interface IBuildWrapParams { + network: CasperNetwork; + publicKey: string; + motesAmount: string; // native CSPR motes to wrap; minted 1:1 as WCSPR + useTransactionV1: boolean; +} + +export interface IBuildUnwrapParams { + network: CasperNetwork; + publicKey: string; + rawAmount: string; // WCSPR units to burn (1:1 with motes at 9 decimals) + useTransactionV1: boolean; +} + +export interface IBuildApprovalParams { + network: CasperNetwork; + publicKey: string; + contractPackageHash: string; + amount: string; // raw motes/base units to approve + useTransactionV1: boolean; +} + +export type IBuildRevokeApprovalParams = Omit; + +export interface IBuildSwapParams { + network: CasperNetwork; + publicKey: string; + firstToken: IDexTokenWithAmount; + secondToken: IDexTokenWithAmount; + path: string[]; + quoteType: SwapQuoteType; + slippage: number; // percent + deadline: number; // minutes + useTransactionV1: boolean; +} diff --git a/src/domain/eip712/errors.ts b/src/domain/eip712/errors.ts index 8c81611..33de270 100644 --- a/src/domain/eip712/errors.ts +++ b/src/domain/eip712/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; export const SignTypedDataErrorCodes = { INVALID_PARAMS: 'INVALID_PARAMS', @@ -24,23 +24,12 @@ export function isEIP712Error(error: unknown | IEIP712Error): error is IEIP712Er return error instanceof EIP712Error && (error).name === 'EIP712Error'; } -export class EIP712Error extends Error implements IEIP712Error { +export class EIP712Error extends DomainError implements IEIP712Error { constructor(error: Error | unknown, type: EIP712ErrorType, errorCode?: SignTypedDataErrorCode) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } + super(error, type, 'EIP712Error'); - this.name = 'EIP712Error'; - this.type = type; this.errorCode = errorCode; } - type: EIP712ErrorType; - traceable: boolean; errorCode?: SignTypedDataErrorCode; } diff --git a/src/domain/errors.contract.test.ts b/src/domain/errors.contract.test.ts new file mode 100644 index 0000000..d24ec76 --- /dev/null +++ b/src/domain/errors.contract.test.ts @@ -0,0 +1,104 @@ +import { AccountInfoError, isAccountInfoError } from './accountInfo/errors'; +import { AppEventsError, isAppEventsError } from './appEvents/errors'; +import { ContractPackageError, isContractPackageError } from './contractPackage/errors'; +import { DexError, isDexError } from './dex/errors'; +import { EIP712Error, isEIP712Error } from './eip712/errors'; +import { isNftsError, NftsError } from './nfts/errors'; +import { isOnRampError, OnRampError } from './onRamp/errors'; +import { isSwapError, SwapError } from './swap/errors'; +import { isTokensError, TokensError } from './tokens/errors'; +import { isTxSignatureRequestError, TxSignatureRequestError } from './tx-signature-request/errors'; +import { isValidatorsError, ValidatorsError } from './validator/errors'; + +const CASES: { + label: string; + build: (e: unknown) => Error & { type: unknown; traceable: boolean; sourceError: unknown }; + name: string; + guard: (e: unknown) => boolean; +}[] = [ + { + label: 'AccountInfoError', + build: e => new AccountInfoError(e, 'getAccountsInfo'), + name: 'AccountInfoRepositoryError', + guard: isAccountInfoError, + }, + { + label: 'AppEventsError', + build: e => new AppEventsError(e, 'getReleaseEvents'), + name: 'AppEventsRepositoryError', + guard: isAppEventsError, + }, + { + label: 'ContractPackageError', + build: e => new ContractPackageError(e, 'getContractPackage'), + name: 'ContractPackageRepositoryError', + guard: isContractPackageError, + }, + { + label: 'DexError', + build: e => new DexError(e, 'getAllowance'), + name: 'DexRepositoryError', + guard: isDexError, + }, + { + label: 'EIP712Error', + build: e => new EIP712Error(e, 'computeDigest'), + name: 'EIP712Error', + guard: isEIP712Error, + }, + { + label: 'NftsError', + build: e => new NftsError(e, 'getNfts'), + name: 'NftsRepositoryError', + guard: isNftsError, + }, + { + label: 'OnRampError', + build: e => new OnRampError(e, 'getOnRampCountriesAndCurrencies'), + name: 'OnRampRepositoryError', + guard: isOnRampError, + }, + { + label: 'SwapError', + build: e => new SwapError(e, 'getQuote'), + name: 'SwapRepositoryError', + guard: isSwapError, + }, + { + label: 'TokensError', + build: e => new TokensError(e, 'getTokens'), + name: 'TokensRepositoryError', + guard: isTokensError, + }, + { + label: 'TxSignatureRequestError', + build: e => new TxSignatureRequestError(e, 'invalidSignatureRequest'), + name: 'TxSignatureRequestRepositoryError', + guard: isTxSignatureRequestError, + }, + { + label: 'ValidatorsError', + build: e => new ValidatorsError(e, 'getCurrentEraId'), + name: 'ValidatorsRepositoryError', + guard: isValidatorsError, + }, +]; + +describe.each(CASES)('$label', ({ build, name, guard }) => { + it('keeps its name, type, message, stack and source error', () => { + const inner = new Error('boom'); + const err = build(inner); + + expect(err.name).toBe(name); + expect(err.type).toBeDefined(); + expect(err.message).toBe('boom'); + expect(err.stack).toBe(inner.stack); + expect(err.traceable).toBe(true); + expect(err.sourceError).toBe(inner); + }); + + it('is matched by its own type guard and by no other error', () => { + expect(guard(build(new Error('boom')))).toBe(true); + expect(guard(new Error('boom'))).toBe(false); + }); +}); diff --git a/src/domain/flows/entities.ts b/src/domain/flows/entities.ts new file mode 100644 index 0000000..3e1c83d --- /dev/null +++ b/src/domain/flows/entities.ts @@ -0,0 +1,126 @@ +// type-only: the domain layer takes no runtime rxjs dependency +import type { Observable } from 'rxjs'; +import type { WrapDirection } from '../dex'; +import type { ILedgerEvent } from '../ledger'; +import type { IDexTokenWithAmount, SwapQuoteType } from '../swap'; +import type { ITransactionSuccessOutcome } from '../transactionStatus'; + +/** Per-leg progress. 'awaiting' means submitted and waiting for the chain. */ +export type TransactionStatus = 'idle' | 'pending' | 'awaiting' | 'success' | 'error'; + +export type SwapLeg = 'approval' | 'swap'; + +export type SwapFlowEvent = + | { type: 'approval:checking' } + | { type: 'approval:not-required' } + | { type: 'approval:signing' } + | { type: 'approval:sent'; hash: string } + | { type: 'approval:confirmed' } + | { type: 'swap:signing' } + | { type: 'swap:sent'; hash: string } + | { type: 'swap:confirmed'; outcome: ITransactionSuccessOutcome } + | { type: 'ledger'; event: ILedgerEvent } + | { type: 'cancelled'; leg: SwapLeg } + | { type: 'failed'; leg: SwapLeg; error: unknown }; + +export type WrapFlowEvent = + | { type: 'wrap:signing' } + | { type: 'wrap:sent'; hash: string } + | { type: 'wrap:confirmed'; outcome: ITransactionSuccessOutcome } + | { type: 'ledger'; event: ILedgerEvent } + | { type: 'cancelled' } + | { type: 'failed'; error: unknown }; + +/** + * A running flow. + * + * `events$` is **hot and replayed**: a late subscriber receives every event so far, and + * unsubscribing does **not** stop the flow — a closed UI surface must not abandon a submitted + * transaction. Stopping is only ever {@link IFlowHandle.cancel}. + */ +export interface IFlowHandle { + readonly id: string; + readonly events$: Observable; + /** Resolves when the flow reaches a terminal state. Never rejects. */ + readonly done: Promise; + cancel(): void; +} + +export type FlowStatus = 'success' | 'failed' | 'cancelled'; + +/** Hashes are on every arm: a cancelled or failed flow may still have submitted a leg. */ +interface ISwapFlowHashes { + approvalHash?: string; + swapHash?: string; +} + +/** + * How a swap flow ended, discriminated on `status`. + * + * On the success arm `outcome` is absent when `awaitSettlement` was `false` — the swap was + * submitted, not observed landing. Read `outcome`, not `status`, to tell the two apart. + */ +export type ISwapFlowResult = + | (ISwapFlowHashes & { status: 'success'; outcome?: ITransactionSuccessOutcome; error?: never }) + | (ISwapFlowHashes & { status: 'failed'; outcome?: never; error: unknown }) + | (ISwapFlowHashes & { status: 'cancelled'; outcome?: never; error?: never }); + +/** See {@link ISwapFlowResult}. */ +export type IWrapFlowResult = + | { status: 'success'; wrapHash?: string; outcome?: ITransactionSuccessOutcome; error?: never } + | { status: 'failed'; wrapHash?: string; outcome?: never; error: unknown } + | { status: 'cancelled'; wrapHash?: string; outcome?: never; error?: never }; + +export type ISwapFlowHandle = IFlowHandle; +export type IWrapFlowHandle = IFlowHandle; + +export interface IStartSwapFlowParams { + firstToken: IDexTokenWithAmount; + secondToken: IDexTokenWithAmount; + path: string[]; + quoteType: SwapQuoteType; + /** Max slippage in percent. Clamp with `clampSlippageValue` before passing it in. */ + slippage: number; + /** Deadline in minutes. Clamp with `clampDeadlineValue` before passing it in. */ + deadline: number; + /** + * Wait for the swap itself to settle before completing. Default `true`. The approval leg is + * always awaited regardless — submitting a swap before its allowance is on chain reverts it. + */ + awaitSettlement?: boolean; +} + +/** + * The four fields that must all come from one and the same quote: `secondToken.amountRaw` becomes + * `amount_out_min`, a slippage bound only on the trade `firstToken.amountRaw` and `path` describe. + */ +export type ISwapQuotedTrade = Pick< + IStartSwapFlowParams, + 'firstToken' | 'secondToken' | 'path' | 'quoteType' +>; + +export interface IStartWrapFlowParams { + direction: WrapDirection; + /** Raw units: motes to wrap, or WCSPR units to burn. */ + rawAmount: string; + awaitSettlement?: boolean; +} + +/** + * A runner is bound to one account for its lifetime — the swap is built from, paid by, signed by + * and delivered to this key. Rebuild the runner when the active account changes. + */ +export interface IFlowRunnerAccount { + readonly publicKey: string; +} + +export interface ISwapFlowRunner extends IFlowRunnerAccount { + start(params: IStartSwapFlowParams): ISwapFlowHandle; + /** The handle for a still-running flow, so a remounted surface can reattach. */ + getActive(id: string): ISwapFlowHandle | null; +} + +export interface IWrapFlowRunner extends IFlowRunnerAccount { + start(params: IStartWrapFlowParams): IWrapFlowHandle; + getActive(id: string): IWrapFlowHandle | null; +} diff --git a/src/domain/flows/errors.ts b/src/domain/flows/errors.ts new file mode 100644 index 0000000..1e73c40 --- /dev/null +++ b/src/domain/flows/errors.ts @@ -0,0 +1,25 @@ +import { IDomainError } from '../common'; + +export type FlowErrorType = 'runner-account-mismatch'; + +export type IFlowError = IDomainError; + +/** + * A flow could not be started. `runner-account-mismatch`: the runner is bound to a different + * account than the active one, so the flow would run against the runner's key. + */ +export class FlowError extends Error implements IFlowError { + constructor(type: FlowErrorType) { + super(`errors:flow-${type}`); + + this.name = 'FlowError'; + this.type = type; + } + + type: FlowErrorType; + traceable = true; +} + +export function isFlowError(error: unknown | IFlowError): error is IFlowError { + return error instanceof FlowError && error.name === 'FlowError'; +} diff --git a/src/domain/flows/index.ts b/src/domain/flows/index.ts new file mode 100644 index 0000000..58a00df --- /dev/null +++ b/src/domain/flows/index.ts @@ -0,0 +1,4 @@ +export * from './entities'; +export * from './errors'; +export * from './swapReducer'; +export * from './wrapReducer'; diff --git a/src/domain/flows/swapReducer.test.ts b/src/domain/flows/swapReducer.test.ts new file mode 100644 index 0000000..22c4242 --- /dev/null +++ b/src/domain/flows/swapReducer.test.ts @@ -0,0 +1,170 @@ +import { initialSwapFlowState, swapFlowReducer } from './swapReducer'; + +import type { ISwapFlowResult, IWrapFlowResult, SwapFlowEvent } from './entities'; +import type { ILedgerEvent } from '../ledger'; + +const reduceAll = (events: SwapFlowEvent[]) => events.reduce(swapFlowReducer, initialSwapFlowState); + +describe('swapFlowReducer', () => { + it('starts on the confirm step with both legs idle', () => { + expect(initialSwapFlowState).toEqual({ + step: 'confirm', + approval: { isRequired: false, status: 'idle' }, + swap: { status: 'idle' }, + }); + }); + + it('moves to the signing step while the approval requirement is being checked', () => { + const state = reduceAll([{ type: 'approval:checking' }]); + + expect(state.step).toBe('signing'); + expect(state.approval.status).toBe('pending'); + }); + + it('marks the approval leg done when no approval is required', () => { + const state = reduceAll([{ type: 'approval:checking' }, { type: 'approval:not-required' }]); + + expect(state.approval).toEqual({ isRequired: false, status: 'success' }); + }); + + it('records the approval hash and awaits settlement once submitted', () => { + const state = reduceAll([ + { type: 'approval:checking' }, + { type: 'approval:signing' }, + { type: 'approval:sent', hash: '0xa' }, + ]); + + expect(state.approval.hash).toBe('0xa'); + expect(state.approval.status).toBe('awaiting'); + expect(state.approval.isRequired).toBe(true); + }); + + it('marks the approval successful once it settles', () => { + const state = reduceAll([ + { type: 'approval:checking' }, + { type: 'approval:signing' }, + { type: 'approval:sent', hash: '0xa' }, + { type: 'approval:confirmed' }, + ]); + + expect(state.approval.status).toBe('success'); + }); + + it('records the swap hash and awaits settlement once submitted', () => { + const state = reduceAll([{ type: 'swap:signing' }, { type: 'swap:sent', hash: '0xb' }]); + + expect(state.swap).toEqual({ status: 'awaiting', hash: '0xb' }); + }); + + it('reaches the success step once the swap settles', () => { + const state = reduceAll([ + { type: 'swap:signing' }, + { type: 'swap:sent', hash: '0xb' }, + { + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }, + ]); + + expect(state.step).toBe('success'); + expect(state.swap.status).toBe('success'); + }); + + it('scopes a swap failure to the swap leg, leaving a successful approval intact', () => { + const state = reduceAll([ + { type: 'approval:checking' }, + { type: 'approval:signing' }, + { type: 'approval:sent', hash: '0xa' }, + { type: 'approval:confirmed' }, + { type: 'failed', leg: 'swap', error: new Error('slippage exceeded') }, + ]); + + expect(state.approval.status).toBe('success'); + expect(state.approval.error).toBeUndefined(); + expect(state.swap.status).toBe('error'); + expect(state.swap.error).toBe('slippage exceeded'); + }); + + it('returns to the confirm step on failure so the surface is retryable in place', () => { + const state = reduceAll([ + { type: 'approval:checking' }, + { type: 'failed', leg: 'approval', error: new Error('nope') }, + ]); + + expect(state.step).toBe('confirm'); + }); + + it('treats cancellation as idle rather than as an error', () => { + const state = reduceAll([{ type: 'swap:signing' }, { type: 'cancelled', leg: 'swap' }]); + + expect(state.swap.status).toBe('idle'); + expect(state.swap.error).toBeUndefined(); + expect(state.step).toBe('confirm'); + }); + + it('records a ledger event without disturbing leg state', () => { + const ledgerEvent = { status: 'waiting-response' } as unknown as ILedgerEvent; + + const state = reduceAll([ + { type: 'swap:signing' }, + { type: 'swap:sent', hash: '0xb' }, + { type: 'ledger', event: ledgerEvent }, + ]); + + expect(state.ledgerEvent).toBe(ledgerEvent); + expect(state.swap.status).toBe('awaiting'); + }); + + it('is pure — it neither mutates its input nor varies between identical applications', () => { + const before = reduceAll([{ type: 'approval:checking' }]); + const snapshot = JSON.parse(JSON.stringify(before)); + + const first = swapFlowReducer(before, { type: 'approval:not-required' }); + const second = swapFlowReducer(before, { type: 'approval:not-required' }); + + expect(before).toEqual(snapshot); + expect(first).toEqual(second); + expect(first).not.toBe(before); + }); + + it('walks the full no-approval happy path to success', () => { + const state = reduceAll([ + { type: 'approval:checking' }, + { type: 'approval:not-required' }, + { type: 'swap:signing' }, + { type: 'swap:sent', hash: '0xb' }, + { + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 3 }, + }, + ]); + + expect(state).toEqual({ + step: 'success', + approval: { isRequired: false, status: 'success' }, + swap: { status: 'success', hash: '0xb' }, + }); + }); +}); + +/** + * Compile-time only: `tsc` covers the test tree, so a `@ts-expect-error` that stops erroring + * fails the build. + */ +describe('flow type contracts', () => { + it('rejects the states the unions exist to forbid', () => { + const reverted: SwapFlowEvent = { + type: 'swap:confirmed', + // @ts-expect-error 'failure' is not assignable to 'success' + outcome: { hash: '0xb', status: 'failure', blockHeight: 3, errorMessage: 'User error: 1' }, + }; + + // @ts-expect-error 'error' is required on the failed arm + const failedWithoutError: ISwapFlowResult = { status: 'failed' }; + + // @ts-expect-error 'error' does not exist on the success arm + const successWithError: IWrapFlowResult = { status: 'success', error: new Error('boom') }; + + expect([reverted, failedWithoutError, successWithError]).toHaveLength(3); + }); +}); diff --git a/src/domain/flows/swapReducer.ts b/src/domain/flows/swapReducer.ts new file mode 100644 index 0000000..f5cdd96 --- /dev/null +++ b/src/domain/flows/swapReducer.ts @@ -0,0 +1,67 @@ +import { getTransactionErrorMessage } from '../../utils/swap'; + +import type { ILedgerEvent } from '../ledger'; +import type { SwapFlowEvent, TransactionStatus } from './entities'; + +export interface ISwapLegState { + status: TransactionStatus; + hash?: string; + error?: string; +} + +export interface ISwapFlowState { + step: 'confirm' | 'signing' | 'success'; + approval: ISwapLegState & { isRequired: boolean }; + swap: ISwapLegState; + ledgerEvent?: ILedgerEvent; +} + +export const initialSwapFlowState: ISwapFlowState = { + step: 'confirm', + approval: { isRequired: false, status: 'idle' }, + swap: { status: 'idle' }, +}; + +export const swapFlowReducer = (state: ISwapFlowState, event: SwapFlowEvent): ISwapFlowState => { + switch (event.type) { + case 'approval:checking': + return { ...state, step: 'signing', approval: { ...state.approval, status: 'pending' } }; + case 'approval:not-required': + return { ...state, approval: { ...state.approval, isRequired: false, status: 'success' } }; + case 'approval:signing': + return { ...state, approval: { ...state.approval, isRequired: true, status: 'pending' } }; + case 'approval:sent': + return { + ...state, + approval: { ...state.approval, isRequired: true, status: 'awaiting', hash: event.hash }, + }; + case 'approval:confirmed': + return { ...state, approval: { ...state.approval, status: 'success' } }; + case 'swap:signing': + return { ...state, step: 'signing', swap: { ...state.swap, status: 'pending' } }; + case 'swap:sent': + return { ...state, swap: { ...state.swap, status: 'awaiting', hash: event.hash } }; + case 'swap:confirmed': + return { ...state, step: 'success', swap: { ...state.swap, status: 'success' } }; + case 'ledger': + return { ...state, ledgerEvent: event.event }; + case 'cancelled': + return { + ...state, + step: 'confirm', + [event.leg]: { ...state[event.leg], status: 'idle' }, + } as ISwapFlowState; + case 'failed': + return { + ...state, + step: 'confirm', + [event.leg]: { + ...state[event.leg], + status: 'error', + error: getTransactionErrorMessage(event.error), + }, + } as ISwapFlowState; + default: + return state; + } +}; diff --git a/src/domain/flows/wrapReducer.ts b/src/domain/flows/wrapReducer.ts new file mode 100644 index 0000000..20ea4a1 --- /dev/null +++ b/src/domain/flows/wrapReducer.ts @@ -0,0 +1,39 @@ +import { getTransactionErrorMessage } from '../../utils/swap'; + +import type { ILedgerEvent } from '../ledger'; +import type { WrapFlowEvent } from './entities'; +import type { ISwapLegState } from './swapReducer'; + +export interface IWrapFlowState { + step: 'confirm' | 'signing' | 'success'; + wrap: ISwapLegState; + ledgerEvent?: ILedgerEvent; +} + +export const initialWrapFlowState: IWrapFlowState = { + step: 'confirm', + wrap: { status: 'idle' }, +}; + +export const wrapFlowReducer = (state: IWrapFlowState, event: WrapFlowEvent): IWrapFlowState => { + switch (event.type) { + case 'wrap:signing': + return { ...state, step: 'signing', wrap: { ...state.wrap, status: 'pending' } }; + case 'wrap:sent': + return { ...state, wrap: { ...state.wrap, status: 'awaiting', hash: event.hash } }; + case 'wrap:confirmed': + return { ...state, step: 'success', wrap: { ...state.wrap, status: 'success' } }; + case 'ledger': + return { ...state, ledgerEvent: event.event }; + case 'cancelled': + return { ...state, step: 'confirm', wrap: { ...state.wrap, status: 'idle' } }; + case 'failed': + return { + ...state, + step: 'confirm', + wrap: { ...state.wrap, status: 'error', error: getTransactionErrorMessage(event.error) }, + }; + default: + return state; + } +}; diff --git a/src/domain/index.ts b/src/domain/index.ts index c9058a1..e03932c 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -10,3 +10,9 @@ export * from './appEvents'; export * from './tx-signature-request'; export * from './contractPackage'; export * from './eip712'; +export * from './swap'; +export * from './dex'; +export * from './casperTransactions'; +export * from './ledger'; +export * from './transactionStatus'; +export * from './flows'; diff --git a/src/domain/ledger/entities.ts b/src/domain/ledger/entities.ts new file mode 100644 index 0000000..6fd6c6d --- /dev/null +++ b/src/domain/ledger/entities.ts @@ -0,0 +1,112 @@ +export enum LedgerEventStatus { + Disconnected = 'ledger-disconnected', + NotAvailable = 'ledger-not-available', + DeviceLocked = 'ledger-device-locked', + WaitingResponseFromDevice = 'ledger-waiting-response-from-device', + WaitingToSignPrevDeploy = 'waiting-to-sign-prev-deploy', + CasperAppNotLoaded = 'ledger-casper-app-not-loaded', + Connected = 'ledger-connected', + LoadingAccountsList = 'ledger-loading-accounts-list', + AccountListUpdated = 'ledger-account-list-updated', + AccountListFailed = 'ledger-account-list-failed', + SignatureRequestedToUser = 'ledger-signature-requested-to-user', + SignatureCompleted = 'ledger-signature-completed', + SignatureCanceled = 'ledger-signature-cancelled', + SignatureFailed = 'ledger-signature-failed', + MsgSignatureRequestedToUser = 'ledger-msg-signature-requested-to-user', + MsgSignatureCompleted = 'ledger-msg-signature-completed', + MsgSignatureCanceled = 'ledger-msg-signature-cancelled', + MsgSignatureFailed = 'ledger-msg-signature-failed', + LedgerPermissionRequired = 'ledger-permission-required', + // The permission window could not be opened — unlike LedgerPermissionRequired, there is no + // window for the user to grant anything in. + PermissionWindowFailed = 'ledger-permission-window-failed', + LedgerAskPermission = 'ledger-ask-permission', + ErrorOpeningDevice = 'ledger-error-opening-device', + Timeout = 'ledger-timeout', + InvalidIndex = 'ledger-invalid-index', + TransactionForOldAppVersion = 'ledger-transaction-for-old-app-version', + BleDeviceSelection = 'ledger-ble-device-selection', + BluetoothPairingInvalidated = 'ledger-bluetooth-pairing-invalidated', +} + +export interface LedgerAccount { + publicKey: string; + index: number; +} + +export interface ILedgerEvent { + status: LedgerEventStatus; + publicKey?: string; + firstAcctIndex?: number; + accounts?: LedgerAccount[]; + txHash?: string; + error?: string; + message?: string; + msgHash?: string; + signatureHex?: string; + appVersion?: string; +} + +export interface LedgerAccountsOptions { + size: number; + offset: number; +} + +export interface SignResult { + signatureHex: string; + signature: Uint8Array; + prefixedSignatureHex: string; + prefixedSignature: Uint8Array; +} + +export type LedgerTransport = 'USB' | 'Bluetooth'; +export type SelectedTransport = LedgerTransport | undefined; + +/** + * The transport surface the Ledger service drives. Structurally satisfied by + * `@ledgerhq/hw-transport`'s `Transport`, which the apps create and own — declaring it here keeps + * that package out of this library's dependency graph. + */ +export interface ILedgerTransport { + close(): Promise; + on(eventName: string, cb: (...args: any[]) => any): void; + off(eventName: string, cb: (...args: any[]) => any): void; + setExchangeTimeout(exchangeTimeout: number): void; +} + +export type TransportCreator = () => Promise; +export type TransportAvailabilityCheck = () => Promise; + +export interface ILedgerResponse { + /** APDU status word: `0x9000` on success. */ + returnCode: number; + errorMessage: string; +} + +export interface ILedgerAppInfoResponse extends ILedgerResponse { + appName: string; + appVersion: string; +} + +export interface ILedgerAddressResponse extends ILedgerResponse { + publicKey: Uint8Array; +} + +export interface ILedgerSignResponse extends ILedgerResponse { + signatureRS: Buffer; + signatureRSV: Buffer; +} + +/** + * The Casper Ledger app the service talks to. Structurally satisfied by `@zondax/ledger-casper`'s + * default export, which the apps construct and pass in via + * {@link ICasperLedgerServiceOptions.createLedgerApp}. + */ +export interface ILedgerCasperApp { + getAppInfo(): Promise; + getAddressAndPubKey(path: string): Promise; + sign(path: string, message: Buffer): Promise; + signWasmDeploy(path: string, message: Buffer): Promise; + signMessage(path: string, message: Buffer): Promise; +} diff --git a/src/domain/ledger/errors.test.ts b/src/domain/ledger/errors.test.ts new file mode 100644 index 0000000..48400f8 --- /dev/null +++ b/src/domain/ledger/errors.test.ts @@ -0,0 +1,57 @@ +import { ILedgerEvent, LedgerEventStatus } from './entities'; +import { isLedgerErrorEvent, LEDGER_ERROR_STATUSES, LedgerError } from './errors'; + +describe('LedgerEventStatus', () => { + it('has all 27 members with mobile-matching string values', () => { + expect(Object.values(LedgerEventStatus)).toHaveLength(27); + expect(LedgerEventStatus.Disconnected).toBe('ledger-disconnected'); + expect(LedgerEventStatus.NotAvailable).toBe('ledger-not-available'); + expect(LedgerEventStatus.WaitingToSignPrevDeploy).toBe('waiting-to-sign-prev-deploy'); + expect(LedgerEventStatus.BleDeviceSelection).toBe('ledger-ble-device-selection'); + expect(LedgerEventStatus.BluetoothPairingInvalidated).toBe( + 'ledger-bluetooth-pairing-invalidated', + ); + }); +}); + +describe('LedgerError', () => { + it('carries the event as its JSON-stringified message and is an Error', () => { + const event: ILedgerEvent = { status: LedgerEventStatus.Disconnected }; + const error = new LedgerError(event); + + expect(error.message).toBe(JSON.stringify(event)); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe('LEDGER_ERROR_STATUSES', () => { + it('contains exactly the statuses both apps mark with a non-null title', () => { + expect([...LEDGER_ERROR_STATUSES].sort()).toEqual( + [ + LedgerEventStatus.Timeout, + LedgerEventStatus.InvalidIndex, + LedgerEventStatus.ErrorOpeningDevice, + LedgerEventStatus.LedgerPermissionRequired, + LedgerEventStatus.MsgSignatureFailed, + LedgerEventStatus.MsgSignatureCanceled, + LedgerEventStatus.SignatureFailed, + LedgerEventStatus.SignatureCanceled, + LedgerEventStatus.AccountListFailed, + LedgerEventStatus.CasperAppNotLoaded, + LedgerEventStatus.DeviceLocked, + LedgerEventStatus.NotAvailable, + LedgerEventStatus.WaitingToSignPrevDeploy, + LedgerEventStatus.TransactionForOldAppVersion, + LedgerEventStatus.BluetoothPairingInvalidated, + ].sort(), + ); + }); +}); + +describe('isLedgerErrorEvent', () => { + it('is true iff the event status is in LEDGER_ERROR_STATUSES', () => { + expect(isLedgerErrorEvent({ status: LedgerEventStatus.Timeout })).toBe(true); + expect(isLedgerErrorEvent({ status: LedgerEventStatus.Connected })).toBe(false); + expect(isLedgerErrorEvent({ status: LedgerEventStatus.PermissionWindowFailed })).toBe(false); + }); +}); diff --git a/src/domain/ledger/errors.ts b/src/domain/ledger/errors.ts new file mode 100644 index 0000000..f25af30 --- /dev/null +++ b/src/domain/ledger/errors.ts @@ -0,0 +1,41 @@ +import { ILedgerEvent, LedgerEventStatus } from './entities'; + +export class LedgerError extends Error { + constructor(readonly ledgerEvent: ILedgerEvent) { + super(JSON.stringify(ledgerEvent)); + } +} + +export const LEDGER_ERROR_STATUSES: ReadonlySet = new Set([ + LedgerEventStatus.Timeout, + LedgerEventStatus.InvalidIndex, + LedgerEventStatus.ErrorOpeningDevice, + LedgerEventStatus.LedgerPermissionRequired, + LedgerEventStatus.MsgSignatureFailed, + LedgerEventStatus.MsgSignatureCanceled, + LedgerEventStatus.SignatureFailed, + LedgerEventStatus.SignatureCanceled, + LedgerEventStatus.AccountListFailed, + LedgerEventStatus.CasperAppNotLoaded, + LedgerEventStatus.DeviceLocked, + LedgerEventStatus.NotAvailable, + LedgerEventStatus.WaitingToSignPrevDeploy, + LedgerEventStatus.TransactionForOldAppVersion, + LedgerEventStatus.BluetoothPairingInvalidated, +]); + +export const isLedgerErrorEvent = (event: ILedgerEvent): boolean => + LEDGER_ERROR_STATUSES.has(event.status); + +/** The two statuses that mean the user declined on the device, rather than anything going wrong. */ +export const LEDGER_CANCELLATION_STATUSES: ReadonlySet = new Set([ + LedgerEventStatus.SignatureCanceled, + LedgerEventStatus.MsgSignatureCanceled, +]); + +/** + * Whether an error is a user's on-device rejection. The default `isCancellationError` for the + * swap and wrap flows. + */ +export const isLedgerSignatureCancelled = (error: unknown): boolean => + error instanceof LedgerError && LEDGER_CANCELLATION_STATUSES.has(error.ledgerEvent.status); diff --git a/src/domain/ledger/index.ts b/src/domain/ledger/index.ts new file mode 100644 index 0000000..2e81704 --- /dev/null +++ b/src/domain/ledger/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './errors'; +export * from './service'; diff --git a/src/domain/ledger/service.ts b/src/domain/ledger/service.ts new file mode 100644 index 0000000..c50abf5 --- /dev/null +++ b/src/domain/ledger/service.ts @@ -0,0 +1,74 @@ +// type-only: a value import would pull the sdk into the domain layer +import type { Transaction } from 'casper-js-sdk'; +import type { Observable, Subscription } from 'rxjs'; +import type { + ILedgerCasperApp, + ILedgerEvent, + ILedgerTransport, + LedgerAccount, + LedgerAccountsOptions, + LedgerEventStatus, + SignResult, + TransportAvailabilityCheck, + TransportCreator, +} from './entities'; + +export interface ICasperLedgerServiceOptions { + /** + * Builds the Casper app for a freshly opened transport, e.g. `t => new CasperApp(t)`. A method, + * not a property, so a factory typed against the app's own transport class is accepted. + */ + createLedgerApp(transport: ILedgerTransport): ILedgerCasperApp; + /** Platform hook: detect transport-level "pairing invalidated" errors (RN BLE shapes). Default: () => false. */ + isPairingInvalidatedError?(e: unknown): boolean; +} + +/** + * A connected Ledger device session. Every method that reports progress does so through + * {@link subscribeToLedgerEventStatus}, and every failure is a `LedgerError` carrying the + * `ILedgerEvent` that describes it. + */ +export interface ICasperLedgerService { + /** Accounts from the last successful {@link getAccountList}; cleared on disconnect. */ + cachedAccounts: LedgerAccount[]; + readonly isConnected: boolean; + subscribeToLedgerEventStatus(onData: (evt: ILedgerEvent) => void): Subscription; + /** + * The same events as {@link subscribeToLedgerEventStatus}, un-debounced, for consumers that + * compose them with another stream — a swap flow merging device prompts into its own progress. + * Replays the current event to every new subscriber. + */ + readonly ledgerEvents$: Observable; + /** @throws {LedgerError} */ + connect( + transportCreator: TransportCreator, + checkTransportAvailability: TransportAvailabilityCheck, + isBluetoothTransport?: boolean, + ): Promise; + disconnect(): Promise; + /** `null` when the Casper app is open and idle; otherwise the status blocking the next call. */ + checkAppInfo(): Promise; + /** @throws {LedgerError} */ + getAccountList(options: LedgerAccountsOptions): Promise; + /** @throws {LedgerError} */ + signTransaction( + tx: Transaction, + account: Partial, + supportsTransactionV1Cb?: (publicKey: string, supports: boolean) => Promise, + tryRestoreConnection?: () => Promise, + ): Promise; + /** @throws {LedgerError} */ + getSignedTransaction( + tx: Transaction, + account: Partial> & Pick, + fallbackTxFromDeploy?: Transaction, + supportsTransactionV1Cb?: (publicKey: string, supports: boolean) => Promise, + tryRestoreConnection?: () => Promise, + ): Promise; + /** @throws {LedgerError} */ + signMessage( + message: string, + account: Partial, + tryRestoreConnection?: () => Promise, + ): Promise>; +} diff --git a/src/domain/nfts/entities.ts b/src/domain/nfts/entities.ts index b5005ea..173bfdd 100644 --- a/src/domain/nfts/entities.ts +++ b/src/domain/nfts/entities.ts @@ -11,7 +11,7 @@ export interface INft extends IEntity { readonly contractPackageHash: string; readonly contractPackageIcon: Maybe; readonly contactName: string; - readonly owner_reverse_lookup_mode: boolean; + readonly ownerReverseLookupMode: boolean; readonly metadata: INftMetadata; readonly previewUrl: Maybe; diff --git a/src/domain/nfts/errors.ts b/src/domain/nfts/errors.ts index 1f82300..ae1657f 100644 --- a/src/domain/nfts/errors.ts +++ b/src/domain/nfts/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; import { INftsRepository } from './repository'; export type NftsErrorType = keyof INftsRepository; @@ -8,21 +8,8 @@ export function isNftsError(error: unknown | INftsError): error is INftsError { return error instanceof NftsError && (error).name === 'NftsRepositoryError'; } -export class NftsError extends Error implements INftsError { +export class NftsError extends DomainError implements INftsError { constructor(error: Error | unknown, type: keyof INftsRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'NftsRepositoryError'; - this.type = type; + super(error, type, 'NftsRepositoryError'); } - - type: NftsErrorType; - traceable: boolean; } diff --git a/src/domain/onRamp/entities.ts b/src/domain/onRamp/entities.ts index 2ce06f1..a824ef3 100644 --- a/src/domain/onRamp/entities.ts +++ b/src/domain/onRamp/entities.ts @@ -7,8 +7,9 @@ import type { import { Maybe } from '../../typings'; // TODO fix it -export interface IOnRampOptions extends Omit { +export interface IOnRampOptions extends Omit { countries: IOnRampCountry[]; + currencies: IOnRampCurrencyItem[]; } export interface IOnRampCountry extends IResponseCountry { @@ -18,7 +19,7 @@ export interface IOnRampCountry extends IResponseCountry { export interface IOnRampCurrencyItem { id: number; code: string; - type_id: string; + typeId: string; rate: number; } diff --git a/src/domain/onRamp/errors.ts b/src/domain/onRamp/errors.ts index 62c5c30..a3c645d 100644 --- a/src/domain/onRamp/errors.ts +++ b/src/domain/onRamp/errors.ts @@ -1,28 +1,15 @@ import { IOnRampRepository } from './repository'; -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; export type OnRampErrorType = keyof IOnRampRepository; export type IOnRampError = IDomainError; export function isOnRampError(error: unknown | OnRampError): error is IOnRampError { - return error instanceof OnRampError && (error).name === 'OnrampRepositoryError'; + return error instanceof OnRampError && (error).name === 'OnRampRepositoryError'; } -export class OnRampError extends Error implements IOnRampError { +export class OnRampError extends DomainError implements IOnRampError { constructor(error: Error | unknown, type: keyof IOnRampRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'OnRampRepositoryError'; - this.type = type; + super(error, type, 'OnRampRepositoryError'); } - - type: OnRampErrorType; - traceable: boolean; } diff --git a/src/domain/swap/entities.ts b/src/domain/swap/entities.ts new file mode 100644 index 0000000..ae04603 --- /dev/null +++ b/src/domain/swap/entities.ts @@ -0,0 +1,45 @@ +import { Maybe } from '../../typings'; + +export interface IDexToken { + readonly id: string; // contract package hash, or the literal 'cspr' for the synthetic native token + readonly name: string; + readonly symbol: string; + readonly icon: Maybe; + readonly decimals: number; + readonly packageHash: string; + readonly isWhitelisted: boolean; + readonly isBlacklisted: boolean; + readonly fiatRates: Maybe; + readonly totalValueLocked: Maybe; + readonly volume24h: Maybe; +} + +export type IDexTokenWithAmount = IDexToken & { + amountFormatted: string; + amountRaw: string; + fiatAmount?: string; +}; + +export enum SwapQuoteType { + ExactIn = 1, + ExactOut = 2, +} + +export interface ISwapQuote { + readonly amountIn: string; + readonly amountOut: string; + readonly executionPrice: string; + readonly midPrice: string; + readonly path: string[]; + readonly priceImpact: string; + readonly recommendedSlippageBps: string; + readonly typeId: SwapQuoteType; + readonly amountInDecimal: string; // derived client-side, not an API field + readonly amountOutDecimal: string; // derived client-side + readonly rate: string; // derived client-side +} + +export enum FetchQuoteErrorCodes { + InvalidAmount = 'invalid_input', + NotFound = 'not_found', +} diff --git a/src/domain/swap/errors.ts b/src/domain/swap/errors.ts new file mode 100644 index 0000000..25dcd6a --- /dev/null +++ b/src/domain/swap/errors.ts @@ -0,0 +1,30 @@ +import { DomainError, HttpError, IDomainError } from '../common'; +import { ISwapRepository } from './repository'; +import { Maybe } from '../../typings'; + +export type SwapErrorType = keyof ISwapRepository; +export type ISwapError = IDomainError & { + /** JSON envelope of the failed response, carried over from `HttpError` when there was one. */ + data?: Maybe; + status?: number; +}; + +export function isSwapError(error: unknown | ISwapError): error is ISwapError { + return error instanceof SwapError && (error).name === 'SwapRepositoryError'; +} + +export class SwapError extends DomainError implements ISwapError { + constructor(error: Error | unknown, type: keyof ISwapRepository) { + super(error, type, 'SwapRepositoryError'); + + // The trade API reports quote failures as a code in the response body + // (`FetchQuoteErrorCodes`); keep the envelope so consumers can still read it after wrapping. + if (error instanceof HttpError) { + this.data = error.data; + this.status = error.status; + } + } + + data?: Maybe; + status?: number; +} diff --git a/src/domain/swap/index.ts b/src/domain/swap/index.ts new file mode 100644 index 0000000..23b69b2 --- /dev/null +++ b/src/domain/swap/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './errors'; +export * from './repository'; diff --git a/src/domain/swap/repository.ts b/src/domain/swap/repository.ts new file mode 100644 index 0000000..2ea409f --- /dev/null +++ b/src/domain/swap/repository.ts @@ -0,0 +1,25 @@ +import type { IDexToken, ISwapQuote, SwapQuoteType } from './entities'; + +import type { CasperNetwork } from '../common/common'; + +export interface ISwapRepository { + getQuote(params: IGetSwapQuoteParams): Promise; + getDexTokens(params: IGetDexTokensParams): Promise; + getDexToken(params: IGetDexTokenParams): Promise; +} + +export interface IGetSwapQuoteParams { + network: CasperNetwork; + tokenIn: IDexToken; + tokenOut: IDexToken; + amount: string; // raw motes/base units + typeId: SwapQuoteType; +} + +export interface IGetDexTokensParams { + network: CasperNetwork; +} +export interface IGetDexTokenParams { + network: CasperNetwork; + contractPackageHash: string; +} diff --git a/src/domain/tokens/errors.ts b/src/domain/tokens/errors.ts index 1284735..05c9193 100644 --- a/src/domain/tokens/errors.ts +++ b/src/domain/tokens/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; import { ITokensRepository } from './repository'; export type TokensErrorType = keyof ITokensRepository; @@ -8,21 +8,8 @@ export function isTokensError(error: unknown | ITokensError): error is ITokensEr return error instanceof TokensError && (error).name === 'TokensRepositoryError'; } -export class TokensError extends Error implements ITokensError { +export class TokensError extends DomainError implements ITokensError { constructor(error: Error | unknown, type: keyof ITokensRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'TokensRepositoryError'; - this.type = type; + super(error, type, 'TokensRepositoryError'); } - - type: TokensErrorType; - traceable: boolean; } diff --git a/src/domain/tokens/repository.ts b/src/domain/tokens/repository.ts index d40fb62..aedc440 100644 --- a/src/domain/tokens/repository.ts +++ b/src/domain/tokens/repository.ts @@ -12,6 +12,8 @@ export interface IGetTokensParams { publicKey: string; network: CasperNetwork; withProxyHeader?: boolean; + /** Restricts the response to these contract packages; omit for every token the account holds. */ + contractPackageHashes?: string[]; } export interface IGetCsprBalanceParams { diff --git a/src/domain/transactionStatus/entities.ts b/src/domain/transactionStatus/entities.ts new file mode 100644 index 0000000..2094a2c --- /dev/null +++ b/src/domain/transactionStatus/entities.ts @@ -0,0 +1,54 @@ +import { DEX_TRANSACTION_TTL_MS } from '../constants'; +import type { CasperNetwork } from '../common'; + +/** How long between settlement polls, and how long to keep polling before giving up. */ +export const DEFAULT_SETTLEMENT_POLL_INTERVAL_MS = 2_000; +/** Matches the TTL transactions are built with, so a timeout means the hash can no longer land. */ +export const DEFAULT_SETTLEMENT_TIMEOUT_MS = DEX_TRANSACTION_TTL_MS; +/** How long lookups may keep failing before the node is called unreachable. */ +export const DEFAULT_LOOKUP_GRACE_MS = 60_000; + +export type TransactionOutcomeStatus = 'success' | 'failure'; + +interface ITransactionOutcomeBase { + hash: string; + blockHeight: number; +} + +/** Executed and did what it was asked. */ +export interface ITransactionSuccessOutcome extends ITransactionOutcomeBase { + status: 'success'; + errorMessage?: never; +} + +/** Executed and reverted. `errorMessage` is the node's own execution error. */ +export interface ITransactionFailureOutcome extends ITransactionOutcomeBase { + status: 'failure'; + errorMessage?: string; +} + +/** + * A transaction that has executed on chain, discriminated on `status`. One still in flight has no + * outcome, and neither has a settlement that timed out — "we stopped waiting" is not "the chain + * rejected it". + */ +export type ITransactionOutcome = ITransactionSuccessOutcome | ITransactionFailureOutcome; + +export interface IWaitForTransactionParams { + hash: string; + network: CasperNetwork; + /** True when the hash was submitted as a legacy Deploy (node 1.x) rather than a TransactionV1. */ + isDeploy: boolean; + /** Default {@link DEFAULT_SETTLEMENT_POLL_INTERVAL_MS}. */ + pollIntervalMs?: number; + /** Default {@link DEFAULT_SETTLEMENT_TIMEOUT_MS}. */ + timeoutMs?: number; + /** + * How long a run of failing lookups is tolerated before the watch reports the node unreachable. + * A single failed poll never ends the watch — the transaction may be landing regardless. + * Default {@link DEFAULT_LOOKUP_GRACE_MS}. + */ + lookupGraceMs?: number; + /** Aborting ends the watch and rejects with {@link TransactionWatchCancelledError}. */ + signal?: AbortSignal; +} diff --git a/src/domain/transactionStatus/errors.test.ts b/src/domain/transactionStatus/errors.test.ts new file mode 100644 index 0000000..94be961 --- /dev/null +++ b/src/domain/transactionStatus/errors.test.ts @@ -0,0 +1,37 @@ +import { + isTransactionStatusError, + isTransactionTimeoutError, + isTransactionWatchCancelledError, + TransactionStatusError, + TransactionTimeoutError, + TransactionWatchCancelledError, +} from './errors'; + +describe('TransactionStatusError', () => { + it('keeps name, type, message, stack, traceable and the source error', () => { + const inner = new Error('lookup exploded'); + const err = new TransactionStatusError(inner, 'lookup'); + + expect(err.name).toBe('TransactionStatusError'); + expect(err.type).toBe('lookup'); + expect(err.message).toBe('lookup exploded'); + expect(err.stack).toBe(inner.stack); + expect(err.traceable).toBe(true); + expect(err.sourceError).toBe(inner); + expect(isTransactionStatusError(err)).toBe(true); + }); + + it('keeps the fixed-message subclasses intact', () => { + const timeout = new TransactionTimeoutError('aabb'); + expect(timeout.message).toBe('errors:transaction-settlement-timeout'); + expect(timeout.type).toBe('timeout'); + expect(timeout.hash).toBe('aabb'); + expect(isTransactionTimeoutError(timeout)).toBe(true); + + const cancelled = new TransactionWatchCancelledError('ccdd'); + expect(cancelled.message).toBe('errors:transaction-watch-cancelled'); + expect(cancelled.type).toBe('cancelled'); + expect(cancelled.hash).toBe('ccdd'); + expect(isTransactionWatchCancelledError(cancelled)).toBe(true); + }); +}); diff --git a/src/domain/transactionStatus/errors.ts b/src/domain/transactionStatus/errors.ts new file mode 100644 index 0000000..821d909 --- /dev/null +++ b/src/domain/transactionStatus/errors.ts @@ -0,0 +1,56 @@ +import { DomainError, IDomainError } from '../common'; + +export type TransactionStatusErrorType = 'timeout' | 'lookup' | 'cancelled'; + +export type ITransactionStatusError = IDomainError; + +export class TransactionStatusError + extends DomainError + implements ITransactionStatusError +{ + constructor(error: Error | unknown, type: TransactionStatusErrorType) { + super(error, type, 'TransactionStatusError'); + } +} + +export function isTransactionStatusError( + error: unknown | ITransactionStatusError, +): error is ITransactionStatusError { + return error instanceof TransactionStatusError && error.name === 'TransactionStatusError'; +} + +/** + * Polling gave up before the transaction executed. Deliberately not a `'failure'` outcome: the + * transaction may still settle, and a caller must be able to tell the two apart. + */ +export class TransactionTimeoutError extends TransactionStatusError { + readonly hash: string; + + constructor(hash: string) { + super(new Error('errors:transaction-settlement-timeout'), 'timeout'); + this.hash = hash; + } +} + +export function isTransactionTimeoutError(error: unknown): error is TransactionTimeoutError { + return error instanceof TransactionTimeoutError; +} + +/** + * The caller aborted the watch. Like a timeout it says nothing about the transaction, which is + * very likely still on its way to a block. + */ +export class TransactionWatchCancelledError extends TransactionStatusError { + readonly hash: string; + + constructor(hash: string) { + super(new Error('errors:transaction-watch-cancelled'), 'cancelled'); + this.hash = hash; + } +} + +export function isTransactionWatchCancelledError( + error: unknown, +): error is TransactionWatchCancelledError { + return error instanceof TransactionWatchCancelledError; +} diff --git a/src/domain/transactionStatus/index.ts b/src/domain/transactionStatus/index.ts new file mode 100644 index 0000000..23b69b2 --- /dev/null +++ b/src/domain/transactionStatus/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './errors'; +export * from './repository'; diff --git a/src/domain/transactionStatus/repository.ts b/src/domain/transactionStatus/repository.ts new file mode 100644 index 0000000..2fb8c87 --- /dev/null +++ b/src/domain/transactionStatus/repository.ts @@ -0,0 +1,24 @@ +// type-only: the domain layer takes no runtime rxjs dependency +import type { Observable } from 'rxjs'; +import type { ITransactionOutcome, IWaitForTransactionParams } from './entities'; + +export interface ITransactionStatusRepository { + /** + * Polls the node until the transaction has executed, then emits exactly one + * {@link ITransactionOutcome} and completes. + * + * Cold: every subscription starts its own poll. Errors with `TransactionTimeoutError` when + * `timeoutMs` elapses first, `TransactionStatusError` of type `'lookup'` when the node stays + * unreachable for `lookupGraceMs`, or `TransactionWatchCancelledError` when `signal` aborts. + */ + observeTransaction(params: IWaitForTransactionParams): Observable; + /** + * Promise facade over {@link observeTransaction}, for callers that do not want rxjs. + * + * @throws {TransactionTimeoutError} when the transaction has not executed within `timeoutMs`. + * @throws {TransactionStatusError} of type `'lookup'` when the node cannot be reached for + * `lookupGraceMs` of consecutive polls. + * @throws {TransactionWatchCancelledError} when `signal` aborts. + */ + waitForTransaction(params: IWaitForTransactionParams): Promise; +} diff --git a/src/domain/tx-signature-request/errors.ts b/src/domain/tx-signature-request/errors.ts index cca1ace..1c6e730 100644 --- a/src/domain/tx-signature-request/errors.ts +++ b/src/domain/tx-signature-request/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; export type TxSignatureRequestErrorType = | 'invalidSignatureRequest' @@ -16,23 +16,13 @@ export function isTxSignatureRequestError( ); } -export class TxSignatureRequestError extends Error implements ITxSignatureRequestError { +export class TxSignatureRequestError + extends DomainError + implements ITxSignatureRequestError +{ constructor(error: Error | unknown, type: TxSignatureRequestErrorType) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'TxSignatureRequestRepositoryError'; - this.type = type; + super(error, type, 'TxSignatureRequestRepositoryError'); } - - type: TxSignatureRequestErrorType; - traceable: boolean; } export class InvalidSignatureRequestError extends TxSignatureRequestError { diff --git a/src/domain/validator/errors.ts b/src/domain/validator/errors.ts index 2fe14be..90bdc83 100644 --- a/src/domain/validator/errors.ts +++ b/src/domain/validator/errors.ts @@ -1,4 +1,4 @@ -import { IDomainError, isDomainError, isError } from '../common'; +import { DomainError, IDomainError } from '../common'; import { IValidatorsRepository } from './repository'; export type ValidatorsErrorType = keyof IValidatorsRepository; @@ -11,21 +11,8 @@ export function isValidatorsError(error: unknown | IValidatorsError): error is I ); } -export class ValidatorsError extends Error implements IValidatorsError { +export class ValidatorsError extends DomainError implements IValidatorsError { constructor(error: Error | unknown, type: keyof IValidatorsRepository) { - if (isError(error)) { - super(error.message); - this.stack = error.stack; - this.traceable = isDomainError(error) ? Boolean(error.traceable) : true; - } else { - super(JSON.stringify(error)); - this.traceable = true; - } - - this.name = 'ValidatorsRepositoryError'; - this.type = type; + super(error, type, 'ValidatorsRepositoryError'); } - - type: ValidatorsErrorType; - traceable: boolean; } diff --git a/src/react/hooks/api/api-hooks.test.tsx b/src/react/hooks/api/api-hooks.test.tsx new file mode 100644 index 0000000..f90e635 --- /dev/null +++ b/src/react/hooks/api/api-hooks.test.tsx @@ -0,0 +1,335 @@ +/** + * @jest-environment jsdom + */ +import { QueryClient } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/react'; + +import { useFetchAccountTokenOwnership } from './useFetchAccountTokenOwnership'; +import { useFetchCsprFiatRates } from './useFetchCsprFiatRates'; +import { useFetchSwapQuote } from './useFetchSwapQuote'; +import { useFetchTokenBalance } from './useFetchTokenBalance'; + +import { + renderHookWithQueryClient, + stubDexContractRepository, + stubSwapRepository, + stubTokensRepository, + TEST_PUBLIC_KEY, +} from '../../../__test-utils__/render-hook'; +import { BLOCK_INTERVAL_MS } from '../../../domain/constants'; +import type { CasperNetwork } from '../../../domain/common/common'; +import type { IDexToken } from '../../../domain/swap'; +import { SwapQuoteType } from '../../../domain/swap'; +import type { ITokenWithFiatBalance } from '../../../domain/tokens'; + +const makeToken = (contractPackageHash: string, balance: string): ITokenWithFiatBalance => + ({ contractPackageHash, balance, decimals: 9 }) as ITokenWithFiatBalance; + +const makeDexToken = (packageHash: string): IDexToken => + ({ id: packageHash, packageHash, decimals: 9 }) as IDexToken; + +describe('useFetchAccountTokenOwnership', () => { + it('reads holdings from the wallet API, narrowed to the requested packages', async () => { + const getTokens = jest.fn().mockResolvedValue([makeToken('cph-1', '100')]); + + const { result } = renderHookWithQueryClient(() => + useFetchAccountTokenOwnership({ + network: 'mainnet', + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ getTokens }), + contractPackageHashes: ['cph-1', 'cph-2'], + }), + ); + + await waitFor(() => expect(result.current.data).toHaveLength(1)); + expect(getTokens).toHaveBeenCalledWith({ + network: 'mainnet', + publicKey: TEST_PUBLIC_KEY, + contractPackageHashes: ['cph-1', 'cph-2'], + }); + }); + + it('does not query while no account is connected', () => { + const getTokens = jest.fn(); + + renderHookWithQueryClient(() => + useFetchAccountTokenOwnership({ + network: 'mainnet', + activePublicKey: null, + tokensRepository: stubTokensRepository({ getTokens }), + }), + ); + + expect(getTokens).not.toHaveBeenCalled(); + }); +}); + +describe('useFetchTokenBalance', () => { + it('picks the balance of the requested contract package', async () => { + const getTokens = jest + .fn() + .mockResolvedValue([makeToken('cph-other', '1'), makeToken('cph-1', '4200')]); + + const { result } = renderHookWithQueryClient(() => + useFetchTokenBalance({ + network: 'mainnet', + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ getTokens }), + contractPackageHash: 'cph-1', + }), + ); + + await waitFor(() => expect(result.current.data).toBe('4200')); + }); + + it('reports "0" for a token the account has never held', async () => { + const getTokens = jest.fn().mockResolvedValue([]); + + const { result } = renderHookWithQueryClient(() => + useFetchTokenBalance({ + network: 'mainnet', + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ getTokens }), + contractPackageHash: 'cph-1', + }), + ); + + await waitFor(() => expect(result.current.data).toBe('0')); + }); + + it('stays undefined until the request resolves, so callers can tell empty from unknown', () => { + const getTokens = jest.fn().mockReturnValue(new Promise(() => {})); + + const { result } = renderHookWithQueryClient(() => + useFetchTokenBalance({ + network: 'mainnet', + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ getTokens }), + contractPackageHash: 'cph-1', + }), + ); + + expect(result.current.data).toBeUndefined(); + }); + + it('requests only the one package it was asked about', async () => { + const getTokens = jest.fn().mockResolvedValue([]); + + const { result } = renderHookWithQueryClient(() => + useFetchTokenBalance({ + network: 'mainnet', + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ getTokens }), + contractPackageHash: 'cph-1', + }), + ); + + await waitFor(() => expect(result.current.data).toBe('0')); + expect(getTokens).toHaveBeenCalledWith( + expect.objectContaining({ contractPackageHashes: ['cph-1'] }), + ); + }); +}); + +describe('useFetchCsprFiatRates', () => { + it('unwraps the rate from the wallet API fiat-rate DTO', async () => { + const getCsprFiatCurrencyRate = jest.fn().mockResolvedValue({ rate: 0.0123, currency: 'USD' }); + + const { result } = renderHookWithQueryClient(() => + useFetchCsprFiatRates({ + network: 'mainnet', + tokensRepository: stubTokensRepository({ getCsprFiatCurrencyRate }), + }), + ); + + await waitFor(() => expect(result.current.csprFiatRates).toBe(0.0123)); + expect(getCsprFiatCurrencyRate).toHaveBeenCalledWith({ network: 'mainnet' }); + }); + + // `IUseFetchCsprFiatRatesParams` has no account field, so "with no account connected" is + // structural rather than something a test can set up. This pins that the rate is + // network-keyed only, which is what would break if account-gating were ever added. + it('keys the rate on the network alone, with no account input', async () => { + const getCsprFiatCurrencyRate = jest.fn().mockResolvedValue({ rate: 1, currency: 'USD' }); + + const { result } = renderHookWithQueryClient(() => + useFetchCsprFiatRates({ + network: 'mainnet', + tokensRepository: stubTokensRepository({ getCsprFiatCurrencyRate }), + }), + ); + + await waitFor(() => expect(result.current.csprFiatRates).toBe(1)); + expect(getCsprFiatCurrencyRate).toHaveBeenCalledWith({ network: 'mainnet' }); + expect(Object.keys(getCsprFiatCurrencyRate.mock.calls[0][0])).toEqual(['network']); + }); +}); + +describe('useFetchAccountTokenOwnership across networks', () => { + it('refetches for the new network instead of serving the previous network cache', async () => { + const getTokens = jest.fn(({ network }: { network: CasperNetwork }) => + Promise.resolve([makeToken('cph-1', network === 'mainnet' ? '1' : '2')]), + ); + const tokensRepository = stubTokensRepository({ getTokens }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + const { result, rerender } = renderHookWithQueryClient( + ({ network }: { network: CasperNetwork }) => + useFetchAccountTokenOwnership({ + network, + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository, + }), + { initialProps: { network: 'mainnet' as CasperNetwork }, queryClient }, + ); + + await waitFor(() => expect(result.current.data?.[0]?.balance).toBe('1')); + + rerender({ network: 'testnet' as CasperNetwork }); + + await waitFor(() => expect(result.current.data?.[0]?.balance).toBe('2')); + expect(getTokens).toHaveBeenCalledTimes(2); + expect(getTokens).toHaveBeenLastCalledWith(expect.objectContaining({ network: 'testnet' })); + }); +}); + +describe('useFetchSwapQuote error handling', () => { + const rejectingQuote = (data: unknown) => + stubSwapRepository({ + getQuote: jest.fn().mockRejectedValue(Object.assign(new Error('Bad Request'), { data })), + }); + + const renderQuote = (swapRepository: ReturnType) => + renderHookWithQueryClient(() => + useFetchSwapQuote({ + network: 'mainnet', + swapRepository, + dexContractRepository: stubDexContractRepository({ + getLatestBlockTime: jest.fn().mockResolvedValue(1000), + }), + typeId: SwapQuoteType.ExactIn, + amount: '1000000000', + tokenIn: makeDexToken('cph-1'), + tokenOut: makeDexToken('cph-2'), + withAutoRefresh: false, + }), + ); + + // The envelope shape mirrors src/data/repositories/swap/swap.test.ts — `SwapError.data` is the + // JSON `HttpDataProvider` builds, so the API's own body sits one level down under `data`. + it('unwraps the trade API error code from the nested envelope', async () => { + const { result } = renderQuote( + rejectingQuote(JSON.stringify({ status: 400, data: { error: { code: 'invalid_input' } } })), + ); + + await waitFor(() => expect(result.current.fetchQuoteErrorCode).toBe('invalid_input')); + }); + + it.each([ + { name: 'a single-level envelope', data: JSON.stringify({ error: { code: 'not_found' } }) }, + { name: 'a body that is not JSON', data: 'gateway timeout' }, + { name: 'no data at all', data: undefined }, + ])('resolves the code to null for $name', async ({ data }) => { + const { result } = renderQuote(rejectingQuote(data)); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.fetchQuoteErrorCode).toBeNull(); + }); +}); + +describe('useFetchSwapQuote auto-refresh', () => { + const QUOTE = { amountIn: '1', amountOut: '2' }; + + const renderAutoRefreshing = (getLatestBlockTime: jest.Mock, getQuote: jest.Mock) => + renderHookWithQueryClient(() => + useFetchSwapQuote({ + network: 'mainnet', + swapRepository: stubSwapRepository({ getQuote }), + dexContractRepository: stubDexContractRepository({ getLatestBlockTime }), + typeId: SwapQuoteType.ExactIn, + amount: '1000000000', + tokenIn: makeDexToken('cph-1'), + tokenOut: makeDexToken('cph-2'), + }), + ); + + // The production caller always takes the default `withAutoRefresh: true` branch, and + // re-quoting every block is the whole reason the latestBlock query exists. Reporting a block + // that landed almost a full interval ago puts the next one ~600ms out, so this does not have + // to sit through a real 8s block. + it('re-quotes on the block schedule', async () => { + const getQuote = jest.fn().mockResolvedValue(QUOTE); + const nearlyDue = Date.now() - (BLOCK_INTERVAL_MS - 100); + const getLatestBlockTime = jest.fn().mockResolvedValue(nearlyDue); + + renderAutoRefreshing(getLatestBlockTime, getQuote); + + await waitFor(() => expect(getQuote).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(getQuote.mock.calls.length).toBeGreaterThan(1), { timeout: 5000 }); + }, 10000); + + // A failed block read used to set the interval to `false`, freezing the displayed quote for + // the whole session while the user believed it was live. The fallback is a flat + // BLOCK_INTERVAL_MS with no way to shorten it, so this test costs one real interval. + it('keeps re-quoting on a fixed interval when the block-time read fails', async () => { + const getQuote = jest.fn().mockResolvedValue(QUOTE); + const getLatestBlockTime = jest.fn().mockRejectedValue(new Error('rpc down')); + + const { result } = renderAutoRefreshing(getLatestBlockTime, getQuote); + + await waitFor(() => expect(result.current.isLatestBlockError).toBe(true), { timeout: 10000 }); + expect(getQuote).toHaveBeenCalledTimes(1); + await waitFor(() => expect(getQuote.mock.calls.length).toBeGreaterThan(1), { + timeout: BLOCK_INTERVAL_MS + 4000, + }); + }, 20000); +}); + +describe('useFetchSwapQuote across networks', () => { + it('refetches the quote and the latest block for the new network', async () => { + const getQuote = jest.fn().mockResolvedValue({ + amountIn: '1000000000', + amountOut: '2000000000', + executionPrice: '2', + midPrice: '2', + path: [], + priceImpact: '0', + recommendedSlippageBps: '50', + typeId: SwapQuoteType.ExactIn, + amountInDecimal: '1', + amountOutDecimal: '2', + rate: '2', + }); + const getLatestBlockTime = jest.fn().mockResolvedValue(1000); + const swapRepository = stubSwapRepository({ getQuote }); + const dexContractRepository = stubDexContractRepository({ getLatestBlockTime }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + const { rerender } = renderHookWithQueryClient( + ({ network }: { network: CasperNetwork }) => + useFetchSwapQuote({ + network, + swapRepository, + dexContractRepository, + typeId: SwapQuoteType.ExactIn, + amount: '1000000000', + tokenIn: makeDexToken('cph-1'), + tokenOut: makeDexToken('cph-2'), + withAutoRefresh: false, + }), + { initialProps: { network: 'mainnet' as CasperNetwork }, queryClient }, + ); + + await waitFor(() => expect(getQuote).toHaveBeenCalledTimes(1)); + + rerender({ network: 'testnet' as CasperNetwork }); + + await waitFor(() => expect(getQuote).toHaveBeenCalledTimes(2)); + expect(getQuote).toHaveBeenLastCalledWith(expect.objectContaining({ network: 'testnet' })); + await waitFor(() => expect(getLatestBlockTime).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/react/hooks/api/useFetchAccountTokenOwnership.ts b/src/react/hooks/api/useFetchAccountTokenOwnership.ts new file mode 100644 index 0000000..c4c0546 --- /dev/null +++ b/src/react/hooks/api/useFetchAccountTokenOwnership.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { ITokensError, ITokenWithFiatBalance } from '../../../domain/tokens'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchAccountTokenOwnershipParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'tokensRepository' +> { + contractPackageHashes?: string[]; + enabled?: boolean; +} + +/** + * CEP-18 holdings from the wallet API — the same source that backs the wallet's own token list, + * so a balance never depends on which screen asked for it. + */ +export const useFetchAccountTokenOwnership = ({ + network, + activePublicKey, + tokensRepository, + contractPackageHashes, + enabled = true, +}: IUseFetchAccountTokenOwnershipParams) => { + const { data, isLoading, error, refetch, isFetching, isError } = useQuery< + ITokenWithFiatBalance[], + ITokensError + >({ + queryKey: [ + 'accountTokenOwnership', + network, + activePublicKey, + contractPackageHashes?.join(',') ?? 'all', + ], + enabled: Boolean(activePublicKey) && enabled, + queryFn: () => { + if (!activePublicKey) { + throw new Error('Public key is required'); + } + + return tokensRepository.getTokens({ + network, + publicKey: activePublicKey, + contractPackageHashes, + }); + }, + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + refetchInterval: 30000, // 30 seconds + }); + + return { data, error, isLoading, refetch, isFetching, isError }; +}; diff --git a/src/react/hooks/api/useFetchCsprFiatRates.ts b/src/react/hooks/api/useFetchCsprFiatRates.ts new file mode 100644 index 0000000..a71a8c9 --- /dev/null +++ b/src/react/hooks/api/useFetchCsprFiatRates.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { ITokensError } from '../../../domain/tokens'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchCsprFiatRatesParams extends Pick< + ISwapDependencies, + 'network' | 'tokensRepository' +> {} + +export const useFetchCsprFiatRates = ({ + network, + tokensRepository, +}: IUseFetchCsprFiatRatesParams) => { + const { + data: csprFiatRates, + isLoading, + error, + } = useQuery({ + queryKey: ['csprFiatRates', network], + queryFn: async () => (await tokensRepository.getCsprFiatCurrencyRate({ network })).rate, + retry: false, + }); + + return { csprFiatRates, error, isLoading }; +}; diff --git a/src/react/hooks/api/useFetchDexTokens.ts b/src/react/hooks/api/useFetchDexTokens.ts new file mode 100644 index 0000000..1173b40 --- /dev/null +++ b/src/react/hooks/api/useFetchDexTokens.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { IDexToken, ISwapError } from '../../../domain/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchDexTokensParams extends Pick< + ISwapDependencies, + 'network' | 'swapRepository' +> {} + +export const useFetchDexTokens = ({ network, swapRepository }: IUseFetchDexTokensParams) => { + return useQuery({ + queryKey: ['tokens', network], + queryFn: () => swapRepository.getDexTokens({ network }), + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + }); +}; diff --git a/src/react/hooks/api/useFetchSwapQuote.ts b/src/react/hooks/api/useFetchSwapQuote.ts new file mode 100644 index 0000000..793ab53 --- /dev/null +++ b/src/react/hooks/api/useFetchSwapQuote.ts @@ -0,0 +1,128 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; + +import type { + FetchQuoteErrorCodes, + IDexToken, + ISwapError, + ISwapQuote, + SwapQuoteType, +} from '../../../domain/swap'; +import { BLOCK_INTERVAL_MS } from '../../../domain/constants'; +import { getMillisecondsUntilNextBlock } from '../../../utils/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchSwapQuoteParams extends Pick< + ISwapDependencies, + 'network' | 'swapRepository' | 'dexContractRepository' +> { + typeId: SwapQuoteType; + amount: string; + tokenIn: IDexToken | null; + tokenOut: IDexToken | null; + withAutoRefresh?: boolean; +} + +// `SwapError.data` is the JSON envelope `HttpDataProvider` builds around a failed response, so +// the API's own body — and the quote error code with it — sits one level down under `data.data`. +const extractFetchQuoteErrorCode = (error: ISwapError | null): FetchQuoteErrorCodes | null => { + const data = error?.data; + + if (typeof data !== 'string') { + return null; + } + + try { + const parsed = JSON.parse(data) as { data?: { error?: { code?: FetchQuoteErrorCodes } } }; + + return parsed.data?.error?.code ?? null; + // A malformed envelope is simply "no code"; the quote error itself is surfaced as `error`. + // eslint-disable-next-line no-restricted-syntax -- deliberate + } catch { + return null; + } +}; + +export const useFetchSwapQuote = ({ + network, + swapRepository, + dexContractRepository, + typeId, + amount, + tokenIn, + tokenOut, + withAutoRefresh = true, +}: IUseFetchSwapQuoteParams) => { + const isFirstRefetch = useRef(true); + const prevQueryKeyRef = useRef(''); + + const { + data: latestBlockTimestamp, + error: latestBlockError, + isError: isLatestBlockError, + } = useQuery({ + queryKey: ['latestBlock', network], + queryFn: () => dexContractRepository.getLatestBlockTime({ network }), + staleTime: Infinity, + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + }); + + const queryKey = ['quote', network, typeId, amount, tokenIn, tokenOut] as const; + const currentQueryKeyString = JSON.stringify(queryKey); + + useEffect(() => { + if (prevQueryKeyRef.current !== currentQueryKeyString) { + isFirstRefetch.current = true; + prevQueryKeyRef.current = currentQueryKeyString; + } + }, [currentQueryKeyString]); + + const { data, isLoading, isFetching, error, refetch, dataUpdatedAt } = useQuery< + ISwapQuote, + ISwapError + >({ + queryKey, + queryFn: () => + swapRepository.getQuote({ + network, + amount, + tokenIn: tokenIn as IDexToken, // cast to IDexToken because enabled only with tokenIn and tokenOut not null + tokenOut: tokenOut as IDexToken, + typeId, + }), + enabled: Boolean(tokenIn && tokenOut && amount && amount !== '0'), + refetchInterval: withAutoRefresh + ? query => { + if (!query.state.data) { + return false; + } + + // Without a block time the quote still has to refresh: "no block time" is not "no + // refresh", or one failed RPC read at mount freezes the displayed price for the + // session and the user signs a stale quote. + return typeof latestBlockTimestamp === 'number' + ? getMillisecondsUntilNextBlock(latestBlockTimestamp) + : BLOCK_INTERVAL_MS; + } + : undefined, + staleTime: 0, + retry: false, + }); + + const fetchQuoteErrorCode = extractFetchQuoteErrorCode(error); + + return { + data, + error, + isLoading, + isFetching, + refetch, + fetchQuoteErrorCode, + dataUpdatedAt, + /** The chain-time read behind the auto-refresh schedule; refresh falls back to a fixed + * interval when it fails, so this is the only signal that it did. */ + latestBlockError, + isLatestBlockError, + }; +}; diff --git a/src/react/hooks/api/useFetchToken.ts b/src/react/hooks/api/useFetchToken.ts new file mode 100644 index 0000000..899f691 --- /dev/null +++ b/src/react/hooks/api/useFetchToken.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { IDexToken, ISwapError } from '../../../domain/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchTokenParams extends Pick< + ISwapDependencies, + 'network' | 'swapRepository' +> { + contractPackageHash: string; +} + +export const useFetchToken = ({ + contractPackageHash, + network, + swapRepository, +}: IUseFetchTokenParams) => { + const { + data: token, + isLoading, + error, + refetch, + isFetching, + isError, + } = useQuery({ + queryKey: ['token', contractPackageHash, network], + queryFn: () => + swapRepository.getDexToken({ + network, + contractPackageHash, + }), + enabled: Boolean(contractPackageHash), + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + }); + + const errorMessage = error?.status === 400 ? 'Pair not found' : error?.message; + + return { token, error, isLoading, refetch, isFetching, isError, errorMessage }; +}; diff --git a/src/react/hooks/api/useFetchTokenBalance.ts b/src/react/hooks/api/useFetchTokenBalance.ts new file mode 100644 index 0000000..dd39231 --- /dev/null +++ b/src/react/hooks/api/useFetchTokenBalance.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; + +import { useFetchAccountTokenOwnership } from './useFetchAccountTokenOwnership'; + +import type { ISwapDependencies } from '../../types'; + +export interface IUseFetchTokenBalanceParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'tokensRepository' +> { + contractPackageHash: string; + enabled?: boolean; +} + +/** + * Single CEP-18 token balance from the wallet API. Narrows {@link useFetchAccountTokenOwnership} + * to one contract package so every balance in the library resolves against one source of truth. + * + * Returns `data` as a raw balance string (smallest unit), or `undefined` while unresolved. + */ +export const useFetchTokenBalance = ({ + network, + activePublicKey, + tokensRepository, + contractPackageHash, + enabled = true, +}: IUseFetchTokenBalanceParams) => { + const contractPackageHashes = useMemo(() => [contractPackageHash], [contractPackageHash]); + + const { data, isLoading, error, refetch, isFetching, isError } = useFetchAccountTokenOwnership({ + network, + activePublicKey, + tokensRepository, + contractPackageHashes, + enabled: Boolean(contractPackageHash) && enabled, + }); + + // An account that has never held the token has no ownership row, which is a zero balance + // rather than missing data. + const balance = useMemo( + () => + data + ? (data.find(token => token.contractPackageHash === contractPackageHash)?.balance ?? '0') + : undefined, + [data, contractPackageHash], + ); + + return { data: balance, error, isLoading, refetch, isFetching, isError }; +}; diff --git a/src/react/hooks/orchestrator-return-shapes.test.tsx b/src/react/hooks/orchestrator-return-shapes.test.tsx new file mode 100644 index 0000000..5c93aaf --- /dev/null +++ b/src/react/hooks/orchestrator-return-shapes.test.tsx @@ -0,0 +1,233 @@ +/** + * @jest-environment jsdom + * + * The four orchestrators are the public API the consuming apps render against. Nothing inside + * this repository reads their return fields, so a rename type-checks clean and ships green — + * and breaks both apps at runtime. These tests are the only thing that fails on it. + */ +import { act } from '@testing-library/react'; +import { NEVER } from 'rxjs'; + +import { useReviewSwap } from './swap/useReviewSwap'; +import { useSwapTokens } from './swap/useSwapTokens'; +import { useReviewWrap } from './wrap/useReviewWrap'; +import { useWrapTokens } from './wrap/useWrapTokens'; + +import { + renderHookWithQueryClient, + stubDexContractRepository, + stubSwapRepository, + stubTokensRepository, + TEST_PUBLIC_KEY, +} from '../../__test-utils__/render-hook'; +import type { ISwapFlowRunner, IWrapFlowRunner } from '../../domain/flows'; +import { SwapQuoteType } from '../../domain/swap'; +import type { IDexToken, IDexTokenWithAmount } from '../../domain/swap'; + +const network = 'mainnet' as const; + +/** Never emits — this suite only asserts the hook's initial return shape. */ +const swapFlowRunner: ISwapFlowRunner = { + publicKey: TEST_PUBLIC_KEY, + start: jest.fn(() => ({ + id: 'flow-1', + events$: NEVER, + done: new Promise(() => {}), + cancel: jest.fn(), + })), + getActive: jest.fn(() => null), +}; + +/** Never emits — this suite only asserts the hook's initial return shape. */ +const wrapFlowRunner: IWrapFlowRunner = { + publicKey: TEST_PUBLIC_KEY, + start: jest.fn(() => ({ + id: 'flow-2', + events$: NEVER, + done: new Promise(() => {}), + cancel: jest.fn(), + })), + getActive: jest.fn(() => null), +}; + +const swapRepository = stubSwapRepository({ + getDexTokens: jest.fn().mockResolvedValue([]), + getQuote: jest.fn().mockResolvedValue(null), +}); +const tokensRepository = stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue({ liquidBalance: '0' }), + getTokens: jest.fn().mockResolvedValue([]), +}); +const dexContractRepository = stubDexContractRepository({ + getLatestBlockTime: jest.fn().mockResolvedValue(Date.now()), + checkApprovalRequired: jest.fn().mockResolvedValue(false), +}); + +const token = (id: string): IDexTokenWithAmount => + ({ + id, + packageHash: id, + decimals: 9, + symbol: id.toUpperCase(), + amountFormatted: '1', + amountRaw: '1000000000', + }) as IDexTokenWithAmount; + +const noop = () => {}; + +/** Lets the async balance reads settle, so their state updates land inside `act`. */ +const flush = () => act(async () => undefined); + +describe('orchestrator return shapes', () => { + it('useSwapTokens', async () => { + const { result } = renderHookWithQueryClient(() => + useSwapTokens({ + network, + activePublicKey: TEST_PUBLIC_KEY, + swapRepository, + dexContractRepository, + tokensRepository, + slippage: 3, + }), + ); + + await flush(); + + expect(Object.keys(result.current).sort()).toEqual([ + 'activeTokenPosition', + 'closeReviewModal', + 'closeTokenSelector', + 'firstTokenFiatAmount', + 'getMaxUsableBalance', + 'getRawTokenBalance', + 'getTokenBalance', + 'handleSwitchTokens', + 'hasTokensSelected', + 'isAmountEntered', + 'isAmountExceedsBalance', + 'isFormValid', + 'isInsufficientCsprForFees', + 'isReviewModalOpen', + 'isTokenSelectorOpen', + 'maxSlippage', + 'networkCost', + 'onSwapSuccess', + 'openReviewModal', + 'openTokenSelector', + 'path', + 'priceImpact', + 'protocolFee', + 'quote', + 'quoteData', + 'quoteType', + 'quotedTrade', + 'resetForm', + 'secondTokenFiatAmount', + 'selectToken', + 'selectedTokens', + 'setInitialTokens', + 'swapRoutes', + 'tokenAmounts', + 'tokens', + 'updateAmount', + ]); + }); + + it('useWrapTokens', async () => { + const { result } = renderHookWithQueryClient(() => + useWrapTokens({ + network, + activePublicKey: TEST_PUBLIC_KEY, + swapRepository, + tokensRepository, + }), + ); + + await flush(); + + expect(Object.keys(result.current).sort()).toEqual([ + 'amount', + 'closeReviewModal', + 'destinationToken', + 'direction', + 'getRawTokenBalance', + 'getTokenBalance', + 'isAmountEntered', + 'isAmountExceedsBalance', + 'isFormValid', + 'isInsufficientCsprForFees', + 'isReviewModalOpen', + 'onWrapSuccess', + 'openReviewModal', + 'resetAmount', + 'sourceRawAmount', + 'sourceToken', + 'sourceTokenFiatAmount', + 'switchDirection', + 'updateAmount', + ]); + }); + + it('useReviewSwap', async () => { + const { result } = renderHookWithQueryClient(() => + useReviewSwap({ + network, + activePublicKey: TEST_PUBLIC_KEY, + swapFlowRunner, + slippage: 3, + deadline: 20, + trade: { + firstToken: token('tokA'), + secondToken: token('tokB'), + path: ['tokA', 'tokB'], + quoteType: SwapQuoteType.ExactIn, + }, + isOpen: true, + onSwapSuccess: noop, + onClose: noop, + }), + ); + + await flush(); + + expect(Object.keys(result.current).sort()).toEqual([ + 'confirmSwap', + 'handleCloseSuccessModal', + 'isProcessing', + 'ledgerEvent', + 'resetForm', + 'step', + 'transactionHash', + 'transactionState', + ]); + expect(Object.keys(result.current.transactionState).sort()).toEqual(['approval', 'swap']); + }); + + it('useReviewWrap', async () => { + const { result } = renderHookWithQueryClient(() => + useReviewWrap({ + network, + activePublicKey: TEST_PUBLIC_KEY, + wrapFlowRunner, + direction: 'wrap', + sourceToken: token('cspr') as unknown as IDexToken & IDexTokenWithAmount, + isOpen: true, + onWrapSuccess: noop, + onClose: noop, + }), + ); + + await flush(); + + expect(Object.keys(result.current).sort()).toEqual([ + 'confirmWrap', + 'error', + 'handleCloseSuccessModal', + 'isProcessing', + 'ledgerEvent', + 'status', + 'step', + 'transactionHash', + ]); + }); +}); diff --git a/src/react/hooks/swap/useReviewSwap.test.tsx b/src/react/hooks/swap/useReviewSwap.test.tsx new file mode 100644 index 0000000..c1e8c4b --- /dev/null +++ b/src/react/hooks/swap/useReviewSwap.test.tsx @@ -0,0 +1,426 @@ +/** + * @jest-environment jsdom + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { ReplaySubject } from 'rxjs'; + +import { useReviewSwap } from './useReviewSwap'; + +import { TEST_PUBLIC_KEY } from '../../../__test-utils__/render-hook'; +import type { ILedgerEvent } from '../../../domain/ledger'; +import { SwapQuoteType } from '../../../domain/swap'; +import type { ISwapFlowHandle, ISwapFlowResult, SwapFlowEvent } from '../../../domain/flows'; +import type { IDexTokenWithAmount } from '../../../domain/swap'; + +const token = (id: string): IDexTokenWithAmount => + ({ id, packageHash: id, decimals: 9, amountRaw: '1000000000', amountFormatted: '1' }) as never; + +/** Test double for a flow runner; `events$` replays like the real one. */ +const makeRunner = (publicKey = TEST_PUBLIC_KEY) => { + const cancel = jest.fn(); + const flows: Array<{ + events$: ReplaySubject; + settle: (result: ISwapFlowResult) => void; + handle: ISwapFlowHandle; + }> = []; + + const start = jest.fn((): ISwapFlowHandle => { + const events$ = new ReplaySubject(Infinity); + let settle!: (result: ISwapFlowResult) => void; + const done = new Promise(resolve => { + settle = resolve; + }); + const handle: ISwapFlowHandle = { + id: `flow-${flows.length + 1}`, + events$: events$.asObservable(), + done, + cancel, + }; + + flows.push({ events$, settle, handle }); + + return handle; + }); + + const current = () => flows[flows.length - 1]; + + return { + cancel, + start, + publicKey, + getActive: jest.fn(() => current()?.handle ?? null), + get events$() { + return current().events$; + }, + settle: (result: ISwapFlowResult = { status: 'success' }) => current().settle(result), + }; +}; + +const setup = (runner: ReturnType, isOpen = true) => + renderHook( + (props: { isOpen: boolean }) => + useReviewSwap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + swapFlowRunner: runner as never, + slippage: 1, + deadline: 20, + trade: { + firstToken: token('in'), + secondToken: token('out'), + path: ['in', 'out'], + quoteType: SwapQuoteType.ExactIn, + }, + isOpen: props.isOpen, + onSwapSuccess: jest.fn(), + onClose: jest.fn(), + }), + { initialProps: { isOpen } }, + ); + +describe('useReviewSwap', () => { + it('reaches the success step when the flow confirms the swap', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'approval:checking' }); + runner.events$.next({ type: 'approval:not-required' }); + runner.events$.next({ type: 'swap:signing' }); + runner.events$.next({ type: 'swap:sent', hash: '0xb' }); + runner.events$.next({ + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }); + }); + + await waitFor(() => expect(result.current.step).toBe('success')); + expect(result.current.transactionState.swap.status).toBe('success'); + expect(result.current.transactionHash).toBe('0xb'); + }); + + it('exposes the approval leg once it has been submitted and settled', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'approval:checking' }); + runner.events$.next({ type: 'approval:signing' }); + runner.events$.next({ type: 'approval:sent', hash: '0xa' }); + runner.events$.next({ type: 'approval:confirmed' }); + }); + + await waitFor(() => expect(result.current.transactionState.approval.status).toBe('success')); + expect(result.current.transactionState.approval.isRequired).toBe(true); + expect(result.current.transactionState.approval.transactionHash).toBe('0xa'); + }); + + it('scopes a swap failure to the swap leg', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'approval:checking' }); + runner.events$.next({ type: 'approval:signing' }); + runner.events$.next({ type: 'approval:sent', hash: '0xa' }); + runner.events$.next({ type: 'approval:confirmed' }); + runner.events$.next({ type: 'failed', leg: 'swap', error: new Error('slippage') }); + }); + + await waitFor(() => expect(result.current.transactionState.swap.error).toBe('slippage')); + expect(result.current.transactionState.approval.status).toBe('success'); + }); + + it('returns to a retryable confirm step after a failure, and actually retries', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'approval:checking' }); + runner.events$.next({ type: 'failed', leg: 'approval', error: new Error('nope') }); + }); + + await waitFor(() => expect(result.current.step).toBe('confirm')); + expect(result.current.isProcessing).toBe(false); + + await act(async () => { + runner.settle({ status: 'failed', error: new Error('nope') }); + }); + + await act(async () => { + result.current.confirmSwap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(2); + }); + + it('refuses a second start while the first flow is still unsettled', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'failed', leg: 'swap', error: new Error('nope') }); + }); + + await waitFor(() => expect(result.current.step).toBe('confirm')); + + await act(async () => { + result.current.resetForm(); + result.current.confirmSwap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(1); + expect(result.current.transactionState.swap.error).toBe('nope'); + }); + + it('refuses to start against a runner bound to a different account', async () => { + const runner = makeRunner('other-account'); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + expect(runner.start).not.toHaveBeenCalled(); + await waitFor(() => + expect(result.current.transactionState.swap.error).toContain('runner-account-mismatch'), + ); + }); + + it('does not start without a quoted trade', async () => { + const runner = makeRunner(); + const { result } = renderHook(() => + useReviewSwap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + swapFlowRunner: runner as never, + slippage: 1, + deadline: 20, + trade: null, + isOpen: true, + onSwapSuccess: jest.fn(), + onClose: jest.fn(), + }), + ); + + await act(async () => { + result.current.confirmSwap(); + }); + + expect(runner.start).not.toHaveBeenCalled(); + }); + + it('replays progress the surface missed while it was closed', async () => { + const runner = makeRunner(); + const onSwapSuccess = jest.fn(); + const { result, rerender } = renderHook( + (props: { isOpen: boolean }) => + useReviewSwap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + swapFlowRunner: runner as never, + slippage: 1, + deadline: 20, + trade: { + firstToken: token('in'), + secondToken: token('out'), + path: ['in', 'out'], + quoteType: SwapQuoteType.ExactIn, + }, + isOpen: props.isOpen, + onSwapSuccess, + onClose: jest.fn(), + }), + { initialProps: { isOpen: true } }, + ); + + await act(async () => { + result.current.confirmSwap(); + }); + + rerender({ isOpen: false }); + + act(() => { + runner.events$.next({ type: 'swap:sent', hash: '0xb' }); + runner.events$.next({ + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }); + }); + + expect(onSwapSuccess).not.toHaveBeenCalled(); + + rerender({ isOpen: true }); + + await waitFor(() => expect(result.current.step).toBe('success')); + expect(onSwapSuccess).toHaveBeenCalledTimes(1); + }); + + it('never starts a second flow while one is already running', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + result.current.confirmSwap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(1); + }); + + it('does not cancel the flow when the modal closes', async () => { + const runner = makeRunner(); + const { result, rerender } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'swap:signing' }); + runner.events$.next({ type: 'swap:sent', hash: '0xb' }); + }); + + rerender({ isOpen: false }); + + expect(runner.cancel).not.toHaveBeenCalled(); + }); + + it('shows the flow’s real progress when the modal is reopened mid-flow', async () => { + const runner = makeRunner(); + const { result, rerender } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'swap:signing' }); + runner.events$.next({ type: 'swap:sent', hash: '0xb' }); + }); + + rerender({ isOpen: false }); + rerender({ isOpen: true }); + + await waitFor(() => expect(result.current.transactionState.swap.status).toBe('awaiting')); + expect(result.current.transactionHash).toBe('0xb'); + }); + + it('does not cancel the flow on unmount', async () => { + const runner = makeRunner(); + const { result, unmount } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + unmount(); + + expect(runner.cancel).not.toHaveBeenCalled(); + }); + + it('calls onSwapSuccess exactly once when the swap confirms', async () => { + const runner = makeRunner(); + const onSwapSuccess = jest.fn(); + + const { result } = renderHook(() => + useReviewSwap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + swapFlowRunner: runner as never, + slippage: 1, + deadline: 20, + trade: { + firstToken: token('in'), + secondToken: token('out'), + path: ['in', 'out'], + quoteType: SwapQuoteType.ExactIn, + }, + isOpen: true, + onSwapSuccess, + onClose: jest.fn(), + }), + ); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }); + runner.events$.next({ + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }); + }); + + await waitFor(() => expect(onSwapSuccess).toHaveBeenCalledTimes(1)); + }); + + it('stops applying events once the modal is closed', async () => { + const runner = makeRunner(); + const { result, rerender } = setup(runner); + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'swap:signing' }); + runner.events$.next({ type: 'swap:sent', hash: '0xb' }); + }); + + rerender({ isOpen: false }); + + act(() => { + runner.events$.next({ + type: 'swap:confirmed', + outcome: { hash: '0xb', status: 'success', blockHeight: 1 }, + }); + }); + + expect(result.current.step).toBe('signing'); + expect(result.current.transactionState.swap.status).toBe('awaiting'); + }); + + it('surfaces a ledger event without disturbing leg statuses', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + const ledgerEvent = { status: 'waiting-response' } as unknown as ILedgerEvent; + + await act(async () => { + result.current.confirmSwap(); + }); + + act(() => { + runner.events$.next({ type: 'swap:signing' }); + runner.events$.next({ type: 'ledger', event: ledgerEvent }); + }); + + await waitFor(() => expect(result.current.ledgerEvent).toBe(ledgerEvent)); + expect(result.current.transactionState.swap.status).toBe('pending'); + }); +}); diff --git a/src/react/hooks/swap/useReviewSwap.ts b/src/react/hooks/swap/useReviewSwap.ts new file mode 100644 index 0000000..db35e1e --- /dev/null +++ b/src/react/hooks/swap/useReviewSwap.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; + +import { initialSwapFlowState, swapFlowReducer } from '../../../domain/flows'; +import type { + ISwapFlowState, + IStartSwapFlowParams, + ISwapFlowHandle, + ISwapQuotedTrade, + SwapFlowEvent, +} from '../../../domain/flows'; +import { FlowError } from '../../../domain/flows'; +import type { ISwapDependencies, TransactionStatus } from '../../types'; + +export interface IUseReviewSwapParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'swapFlowRunner' +> { + /** Max slippage in percent — the same value passed to the swap build. */ + slippage: number; + /** Transaction deadline in minutes. */ + deadline: number; + /** + * The tokens, amounts, route and quote type of one quote — `useSwapTokens` returns it as + * `quotedTrade`. `null` while no quote is in hand, which makes `confirmSwap` a no-op. + */ + trade: ISwapQuotedTrade | null; + isOpen: boolean; + onSwapSuccess: () => void; + onClose: () => void; +} + +/** `reset` is a view concern the runner never emits — it backs `resetForm`. */ +type SwapViewEvent = SwapFlowEvent | { type: 'reset' }; + +const swapViewReducer = (state: ISwapFlowState, event: SwapViewEvent): ISwapFlowState => + event.type === 'reset' ? initialSwapFlowState : swapFlowReducer(state, event); + +export interface ISwapTransactionState { + approval: { + isRequired: boolean; + status: TransactionStatus; + transactionHash?: string; + error?: string; + }; + swap: { status: TransactionStatus; error?: string }; +} + +/** + * Subscribes the review modal to a running swap flow. Closing the modal (`isOpen: false`) + * unsubscribes but never cancels the handle, so a submitted swap keeps running and reopening + * replays the flow's history. Only `handle.cancel()` stops a flow. + */ +export const useReviewSwap = ({ + activePublicKey, + swapFlowRunner, + slippage, + deadline, + trade, + isOpen, + onSwapSuccess, + onClose, +}: IUseReviewSwapParams) => { + const [handle, setHandle] = useState(null); + const [state, dispatch] = useReducer(swapViewReducer, initialSwapFlowState); + const succeededRef = useRef(false); + // Non-null while a flow is live. A ref, not state: `confirmSwap` can fire twice in one tick, + // before `setHandle` has re-rendered. + const handleRef = useRef(null); + // Held in a ref rather than a dependency: an inline callback would change identity every + // render, resubscribing and restarting the fold. + const onSwapSuccessRef = useRef(onSwapSuccess); + onSwapSuccessRef.current = onSwapSuccess; + + const releaseGuard = useCallback((settled: ISwapFlowHandle) => { + if (handleRef.current === settled) { + handleRef.current = null; + } + }, []); + + useEffect(() => { + // Unsubscribing while the surface is closed only stops the hook from applying events; the + // flow keeps running. `events$` is replayed, so reopening re-folds the whole history onto the + // state already folded — every reducer case overwrites, so that converges rather than doubles. + if (!handle || !isOpen) return; + + const subscription = handle.events$.subscribe({ + next: event => { + dispatch(event); + + if (event.type === 'swap:confirmed' && !succeededRef.current) { + succeededRef.current = true; + onSwapSuccessRef.current(); + } + }, + // The flow never errors the stream, but a merged `ledgerEvents$` can; without a handler + // rxjs rethrows out of band and the surface stays on `confirm` with no reason. + error: (error: unknown) => dispatch({ type: 'failed', leg: 'swap', error }), + }); + + // Unsubscribe only — cancelling here would abandon a submitted swap. + return () => subscription.unsubscribe(); + }, [handle, isOpen]); + + const confirmSwap = useCallback(() => { + if (handleRef.current || !swapFlowRunner || !activePublicKey || !trade) return; + + if (swapFlowRunner.publicKey !== activePublicKey) { + dispatch({ + type: 'failed', + leg: 'swap', + error: new FlowError('runner-account-mismatch'), + }); + + return; + } + + const params: IStartSwapFlowParams = { ...trade, slippage, deadline }; + + const newHandle = swapFlowRunner.start(params); + handleRef.current = newHandle; + setHandle(newHandle); + // Only `done` releases the guard; clearing it on unsubscribe would miss a flow that ended + // while the surface was closed. + const release = () => releaseGuard(newHandle); + newHandle.done.then(release, release); + }, [activePublicKey, deadline, releaseGuard, slippage, swapFlowRunner, trade]); + + const resetForm = useCallback(() => { + // Refusing while a flow is live stops a second swap against the same balance. To stop a + // flow, use `handle.cancel()`. + if (handleRef.current) return; + + succeededRef.current = false; + setHandle(null); + dispatch({ type: 'reset' }); + }, []); + + const transactionState: ISwapTransactionState = { + approval: { + isRequired: state.approval.isRequired, + status: state.approval.status, + transactionHash: state.approval.hash, + error: state.approval.error, + }, + swap: { + status: state.swap.status, + error: state.swap.error, + }, + }; + + const handleCloseSuccessModal = useCallback(() => { + onClose(); + }, [onClose]); + + return { + step: state.step, + transactionState, + isProcessing: state.step === 'signing', + confirmSwap, + resetForm, + handleCloseSuccessModal, + transactionHash: state.swap.hash ?? null, + ledgerEvent: state.ledgerEvent, + }; +}; diff --git a/src/react/hooks/swap/useSwapRouteTokens.ts b/src/react/hooks/swap/useSwapRouteTokens.ts new file mode 100644 index 0000000..0431ede --- /dev/null +++ b/src/react/hooks/swap/useSwapRouteTokens.ts @@ -0,0 +1,64 @@ +import { useQueries } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { WrappedCsprContractPackageHash } from '../../../domain/constants'; +import type { IDexToken } from '../../../domain/swap'; +import { getSwapRoutes, type WcsprDisplay } from '../../../utils/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseSwapRouteTokensParams extends Pick< + ISwapDependencies, + 'network' | 'swapRepository' +> { + path?: string[]; + tokens?: IDexToken[]; + enabled?: boolean; + wcsprDisplay?: WcsprDisplay; +} + +/** + * Resolves the token objects for a quote's `path` hashes: known tokens (`tokens`) are used + * as-is, anything missing (e.g. a route hop outside the listed-token set) is fetched + * individually. The per-hash query key `['token', hash, network]` must stay identical to + * `useFetchToken`'s — the two share the cache entry. + */ +export const useSwapRouteTokens = ({ + network, + swapRepository, + path = [], + tokens = [], + enabled = true, + wcsprDisplay = 'native', +}: IUseSwapRouteTokensParams): IDexToken[] => { + const wrappedCsprPackageHash = WrappedCsprContractPackageHash[network]; + + const missingHashes = useMemo( + () => + path.filter( + hash => + hash !== wrappedCsprPackageHash && + !tokens.some(token => token.packageHash.toLowerCase() === hash.toLowerCase()), + ), + [path, tokens, wrappedCsprPackageHash], + ); + + const queries = useQueries({ + queries: missingHashes.map(hash => ({ + queryKey: ['token', hash, network], + queryFn: () => swapRepository.getDexToken({ network, contractPackageHash: hash }), + enabled: enabled && Boolean(hash), + retry: 3, + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000), + })), + }); + + return useMemo(() => { + const fetchedTokens = queries + .map(query => query.data) + .filter((token): token is IDexToken => Boolean(token)); + + return getSwapRoutes(path, [...tokens, ...fetchedTokens], wrappedCsprPackageHash, { + wcsprDisplay, + }); + }, [path, tokens, queries, wcsprDisplay, wrappedCsprPackageHash]); +}; diff --git a/src/react/hooks/swap/useSwapTokens.quotedTrade.test.tsx b/src/react/hooks/swap/useSwapTokens.quotedTrade.test.tsx new file mode 100644 index 0000000..6bcb76d --- /dev/null +++ b/src/react/hooks/swap/useSwapTokens.quotedTrade.test.tsx @@ -0,0 +1,83 @@ +/** + * @jest-environment jsdom + */ +import { act, waitFor } from '@testing-library/react'; + +import { useSwapTokens } from './useSwapTokens'; + +import { + renderHookWithQueryClient, + stubDexContractRepository, + stubSwapRepository, + stubTokensRepository, + TEST_PUBLIC_KEY, +} from '../../../__test-utils__/render-hook'; +import type { IDexToken } from '../../../domain/swap'; + +const network = 'mainnet' as const; + +const dexToken = (id: string, decimals: number): IDexToken => + ({ id, packageHash: `${id}-hash`, decimals, symbol: id.toUpperCase() }) as IDexToken; + +const TOKEN_IN = dexToken('token-in', 9); +const TOKEN_OUT = dexToken('token-out', 6); + +const makeDeps = (quote: unknown) => ({ + swapRepository: stubSwapRepository({ + getDexTokens: jest.fn().mockResolvedValue([TOKEN_IN, TOKEN_OUT]), + getQuote: jest.fn().mockResolvedValue(quote), + }), + tokensRepository: stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue({ liquidBalance: '0' }), + getTokens: jest.fn().mockResolvedValue([]), + }), + dexContractRepository: stubDexContractRepository({ + getLatestBlockTime: jest.fn().mockResolvedValue(Date.now()), + checkApprovalRequired: jest.fn().mockResolvedValue(false), + }), +}); + +const renderSwapTokens = (quote: unknown) => { + const deps = makeDeps(quote); + + return renderHookWithQueryClient(() => + useSwapTokens({ network, activePublicKey: TEST_PUBLIC_KEY, slippage: 3, ...deps }), + ); +}; + +describe('useSwapTokens quotedTrade', () => { + it('is null while no quote is in hand', async () => { + const { result } = renderSwapTokens(null); + + await act(async () => undefined); + + expect(result.current.quotedTrade).toBeNull(); + }); + + // The stubbed quote echoes an `amountInDecimal` the form never held. + it('reads both amounts and the route off the same quote, not off the form', async () => { + const { result } = renderSwapTokens({ + path: [TOKEN_IN.packageHash, TOKEN_OUT.packageHash], + amountInDecimal: '1.5', + amountOutDecimal: '2.25', + rate: '1.5', + priceImpact: '0.1', + }); + + await act(async () => { + result.current.setInitialTokens(TOKEN_IN, TOKEN_OUT); + }); + + await act(async () => { + result.current.updateAmount('first', '9'); + }); + + await waitFor(() => expect(result.current.quotedTrade).not.toBeNull(), { timeout: 3000 }); + + expect(result.current.quotedTrade).toMatchObject({ + firstToken: { id: TOKEN_IN.id, amountFormatted: '1.5', amountRaw: '1500000000' }, + secondToken: { id: TOKEN_OUT.id, amountFormatted: '2.25', amountRaw: '2250000' }, + path: [TOKEN_IN.packageHash, TOKEN_OUT.packageHash], + }); + }); +}); diff --git a/src/react/hooks/swap/useSwapTokens.ts b/src/react/hooks/swap/useSwapTokens.ts new file mode 100644 index 0000000..076d793 --- /dev/null +++ b/src/react/hooks/swap/useSwapTokens.ts @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useMemo } from 'react'; + +import { useSwapRouteTokens } from './useSwapRouteTokens'; + +import { useFetchDexTokens } from '../api/useFetchDexTokens'; +import { useFetchSwapQuote } from '../api/useFetchSwapQuote'; +import { useCsprFeeValidation } from '../token/useCsprFeeValidation'; +import { useTokenBalances } from '../token/useTokenBalances'; +import { useTokenPairBalances } from '../token/useTokenPairBalances'; +import { useTokenPairFiatAmounts } from '../token/useTokenPairFiatAmounts'; +import { useTokenPairState } from '../token/useTokenPairState'; +import { useTokenPreselection } from '../token/useTokenPreselection'; + +import { + CSPR_NATIVE_TOKEN_ID, + DEX_PAYMENT_AMOUNT, + TOKEN_DISPLAY_DECIMALS, + USD_CURRENCY_CODE, +} from '../../../domain/constants'; +import type { ISwapQuotedTrade } from '../../../domain/flows'; +import type { IDexToken } from '../../../domain/swap'; +import { SwapQuoteType } from '../../../domain/swap'; +import { isAmountInputValid, isPositiveAmount } from '../../../utils/amounts'; +import { formatTokenBalance, getBlockchainAmount } from '../../../utils/common'; +import { + calculateMaxUsableBalance, + calculateSwapFee, + calculateSwapPaymentAmount, + handleTokenSelection, + type TokenPosition, +} from '../../../utils/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseSwapTokensParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'swapRepository' | 'dexContractRepository' | 'tokensRepository' +> { + /** Max slippage in percent, shown as `maxSlippage`. Clamp with `clampSlippageValue`. */ + slippage: number; + /** Deep-link token hashes; router/URL parsing is the consumer's job. */ + tokenInHash?: string; + tokenOutHash?: string; +} + +/** + * Trade-page orchestrator: composes token-pair state, balances, quote fetching and the review + * modal into one form API. Return-field names are public API for the apps consuming this library. + */ +export const useSwapTokens = ({ + network, + activePublicKey, + swapRepository, + dexContractRepository, + tokensRepository, + slippage, + tokenInHash, + tokenOutHash, +}: IUseSwapTokensParams) => { + const isWalletConnected = Boolean(activePublicKey); + + const { data: tokens } = useFetchDexTokens({ network, swapRepository }); + + const { + selectedTokens, + tokenAmounts, + activeTokenPosition, + hasBothTokens, + debouncedTokenAmounts, + tokensWithAmounts, + customTokenHashes, + setSelectedTokens, + setTokenAmounts, + setActiveTokenPosition, + setInitialTokens, + resetToDefaultTokens, + tokenSelectorModal, + reviewModal, + } = useTokenPairState({ tokens }); + + const { + getFormattedBalance, + getRawBalance, + resetBalances, + refetchCsprBalance, + refetchTokenBalances, + } = useTokenBalances({ + network, + activePublicKey, + tokensRepository, + swapRepository, + additionalContractPackageHashes: customTokenHashes, + }); + + const quoteType = + activeTokenPosition === 'first' ? SwapQuoteType.ExactIn : SwapQuoteType.ExactOut; + + const { firstTokenFiatAmount, secondTokenFiatAmount, csprFiatRates } = useTokenPairFiatAmounts({ + network, + tokensRepository, + firstToken: selectedTokens.first, + secondToken: selectedTokens.second, + firstTokenAmount: tokenAmounts.first.formatted || '0', + secondTokenAmount: tokenAmounts.second.formatted || '0', + }); + + const rawAmount = useMemo(() => { + const isFirst = activeTokenPosition === 'first'; + const token = isFirst ? selectedTokens.first : selectedTokens.second; + const formatted = isFirst + ? debouncedTokenAmounts.first.formatted + : debouncedTokenAmounts.second.formatted; + + if (!token) return '0'; + + return formatted && typeof token.decimals === 'number' + ? getBlockchainAmount(formatted, token.decimals, '0') + : '0'; + }, [activeTokenPosition, debouncedTokenAmounts, selectedTokens.first, selectedTokens.second]); + + const quoteData = useFetchSwapQuote({ + network, + swapRepository, + dexContractRepository, + tokenIn: tokenSelectorModal.isOpen ? null : selectedTokens.first, + tokenOut: tokenSelectorModal.isOpen ? null : selectedTokens.second, + amount: rawAmount, + typeId: quoteType, + }); + + const { getTokenBalance, getRawTokenBalance, isAmountExceedsBalance } = useTokenPairBalances({ + selectedTokens, + tokenAmounts, + getFormattedBalance, + getRawBalance, + isWalletConnected, + }); + + const getMaxUsableBalance = useCallback( + (position: TokenPosition): string => { + const token = selectedTokens[position]; + if (!token) return '0'; + + const balance = getTokenBalance(position); + + return calculateMaxUsableBalance({ + balance, + symbol: token.symbol, + context: 'swap', + }); + }, + [selectedTokens, getTokenBalance], + ); + + const transactionFeeInMotes = useMemo(() => { + const isSwappingTokenForToken = + selectedTokens.first?.id !== CSPR_NATIVE_TOKEN_ID && + selectedTokens.second?.id !== CSPR_NATIVE_TOKEN_ID; + const swapFee = isSwappingTokenForToken + ? DEX_PAYMENT_AMOUNT.swapTokenForToken + : DEX_PAYMENT_AMOUNT.swapCsprForToken; + + return (BigInt(DEX_PAYMENT_AMOUNT.approve) + BigInt(swapFee)).toString(); + }, [selectedTokens.first?.id, selectedTokens.second?.id]); + + const isInsufficientCsprForFees = useCsprFeeValidation({ + selectedTokens, + tokenAmounts, + getRawBalance, + feeInMotes: transactionFeeInMotes, + isWalletConnected, + }); + + const isAmountEntered = isPositiveAmount(tokenAmounts.first.formatted); + + const isFormValid = Boolean( + selectedTokens.first && + selectedTokens.second && + !selectedTokens.first.isBlacklisted && + !selectedTokens.second.isBlacklisted && + isPositiveAmount(tokenAmounts.first.formatted) && + isPositiveAmount(tokenAmounts.second.formatted) && + !isAmountExceedsBalance('first') && + !isInsufficientCsprForFees() && + quoteData.data, + ); + + const openTokenSelector = useCallback( + (position: TokenPosition) => { + setActiveTokenPosition(position); + tokenSelectorModal.open(); + }, + [setActiveTokenPosition, tokenSelectorModal], + ); + + const selectToken = useCallback( + (token: IDexToken) => { + setSelectedTokens(prev => handleTokenSelection(prev, token, activeTokenPosition)); + + if (activeTokenPosition === 'first') { + setTokenAmounts({ + first: { formatted: '0', raw: '0' }, + second: { formatted: '0', raw: '0' }, + }); + } else { + setTokenAmounts(prev => ({ first: prev.first, second: { formatted: '0', raw: '0' } })); + setActiveTokenPosition('first'); + } + + tokenSelectorModal.close(); + }, + [ + activeTokenPosition, + setActiveTokenPosition, + setSelectedTokens, + setTokenAmounts, + tokenSelectorModal, + ], + ); + + const handleSwitchTokens = useCallback(() => { + const nextFirstToken = selectedTokens.second; + setSelectedTokens(prev => ({ first: prev.second, second: prev.first })); + setActiveTokenPosition('first'); + + setTokenAmounts(prev => ({ + first: { + formatted: prev.first.formatted, + raw: + typeof nextFirstToken?.decimals === 'number' + ? getBlockchainAmount(prev.first.formatted, nextFirstToken.decimals, '0') + : prev.first.raw, + }, + second: { formatted: '0', raw: '0' }, + })); + }, [selectedTokens.second, setActiveTokenPosition, setSelectedTokens, setTokenAmounts]); + + const updateAmount = useCallback( + (position: TokenPosition, amount: string) => { + const currentToken = selectedTokens[position]; + const currentDecimals = currentToken?.decimals; + + if (amount !== '' && !isAmountInputValid(amount, currentDecimals)) { + return; + } + + setTokenAmounts(prev => { + const formatted = amount; + const raw = + amount && typeof currentDecimals === 'number' + ? getBlockchainAmount(formatted, currentDecimals, '0') + : '0'; + + const next = { ...prev, [position]: { formatted, raw } }; + + if (amount === '' || amount === '0') { + return { + first: { formatted: '0', raw: '0' }, + second: { formatted: '0', raw: '0' }, + }; + } + + return next; + }); + + setActiveTokenPosition(position); + }, + [selectedTokens, setActiveTokenPosition, setTokenAmounts], + ); + + useEffect(() => { + if ( + selectedTokens.first && + selectedTokens.second && + quoteData.data?.amountInDecimal && + quoteData.data?.amountOutDecimal + ) { + if (activeTokenPosition === 'first') { + const formatted = quoteData.data.amountOutDecimal; + const raw = + typeof selectedTokens.second.decimals === 'number' + ? getBlockchainAmount(formatted, selectedTokens.second.decimals, '0') + : '0'; + + setTokenAmounts(prev => ({ ...prev, second: { formatted, raw } })); + } else { + const formatted = quoteData.data.amountInDecimal; + const raw = + typeof selectedTokens.first.decimals === 'number' + ? getBlockchainAmount(formatted, selectedTokens.first.decimals, '0') + : '0'; + + setTokenAmounts(prev => ({ ...prev, first: { formatted, raw } })); + } + } + }, [ + setTokenAmounts, + activeTokenPosition, + quoteData.data?.amountInDecimal, + quoteData.data?.amountOutDecimal, + selectedTokens.first, + selectedTokens.second, + ]); + + const openReviewModal = useCallback(async () => { + if (!tokensWithAmounts.first || !tokensWithAmounts.second) { + return; + } + + reviewModal.open(); + }, [tokensWithAmounts, reviewModal]); + + const onSwapSuccess = useCallback(() => { + setTokenAmounts({ + first: { formatted: '0', raw: '0' }, + second: { formatted: '0', raw: '0' }, + }); + refetchCsprBalance().catch(() => { + // best-effort background refresh; consumers can retry via the returned refetchCsprBalance + }); + // No catch: this resolves to a react-query `refetch()`, which swallows its own rejection + // (QueryObserver only rethrows under `throwOnError`), so there is nothing here to catch. + refetchTokenBalances(); + }, [refetchCsprBalance, refetchTokenBalances, setTokenAmounts]); + + const resetForm = useCallback(() => { + resetToDefaultTokens(); + resetBalances(); + }, [resetBalances, resetToDefaultTokens]); + + const quoteFirstSymbol = + activeTokenPosition === 'first' ? selectedTokens.first?.symbol : selectedTokens.second?.symbol; + const quoteSecondSymbol = + activeTokenPosition === 'first' ? selectedTokens.second?.symbol : selectedTokens.first?.symbol; + + const quote = + selectedTokens.first && selectedTokens.second && quoteData.data?.rate + ? `1 ${quoteFirstSymbol} = ${formatTokenBalance(quoteData.data.rate, 0, TOKEN_DISPLAY_DECIMALS, '0', true)} ${quoteSecondSymbol}` + : null; + + const priceImpact = quoteData.data?.priceImpact + ? Number(quoteData.data.priceImpact).toFixed(2) + : null; + const protocolFee = selectedTokens.first + ? `${calculateSwapFee(tokenAmounts.first.formatted)} ${selectedTokens.first.symbol}` + : null; + + const networkCost = calculateSwapPaymentAmount( + selectedTokens.first, + selectedTokens.second, + csprFiatRates ?? null, + USD_CURRENCY_CODE, + ); + const maxSlippage = `${slippage}`; + const swapRoutes = useSwapRouteTokens({ + network, + swapRepository, + path: quoteData.data?.path ?? [], + tokens: tokens ?? [], + }); + const path = quoteData.data?.path ?? []; + + /** + * The one bundle a swap may be started from: every field is read off the same quote. The form's + * own `tokenAmounts` lag it by the input debounce and must not be used here. + */ + const quotedTrade = useMemo(() => { + const quoteResult = quoteData.data; + const { first, second } = selectedTokens; + + if ( + !first || + !second || + !quoteResult?.amountInDecimal || + !quoteResult?.amountOutDecimal || + typeof first.decimals !== 'number' || + typeof second.decimals !== 'number' + ) { + return null; + } + + return { + firstToken: { + ...first, + amountFormatted: quoteResult.amountInDecimal, + amountRaw: getBlockchainAmount(quoteResult.amountInDecimal, first.decimals, '0'), + }, + secondToken: { + ...second, + amountFormatted: quoteResult.amountOutDecimal, + amountRaw: getBlockchainAmount(quoteResult.amountOutDecimal, second.decimals, '0'), + }, + path: quoteResult.path, + quoteType, + }; + }, [quoteData.data, quoteType, selectedTokens]); + + // `setInitialTokens` takes `(first, second)`; `useTokenPreselection` passes a single + // `{ first, second }` object — adapt here rather than changing either hook's signature. + const setInitialTokensForPreselection = useCallback( + (nextTokens: { first: IDexToken | null; second: IDexToken | null }) => { + if (!nextTokens.first) return; + + setInitialTokens(nextTokens.first, nextTokens.second); + }, + [setInitialTokens], + ); + + useTokenPreselection({ + network, + swapRepository, + tokenInHash, + tokenOutHash, + tokens: tokens ?? [], + setInitialTokens: setInitialTokensForPreselection, + setSelectedTokens, + }); + + return { + // State + selectedTokens, + tokenAmounts, + isTokenSelectorOpen: tokenSelectorModal.isOpen, + isReviewModalOpen: reviewModal.isOpen, + activeTokenPosition, + + // Computed values + isFormValid, + isAmountEntered, + hasTokensSelected: hasBothTokens, + + // Actions + openTokenSelector, + closeTokenSelector: tokenSelectorModal.close, + selectToken, + updateAmount, + resetForm, + onSwapSuccess, + openReviewModal, + closeReviewModal: reviewModal.close, + handleSwitchTokens, + + // Validation helpers + getTokenBalance, + getRawTokenBalance, + isAmountExceedsBalance, + isInsufficientCsprForFees, + getMaxUsableBalance, + + // Quote data and calculations + quoteData, + quote, + priceImpact, + protocolFee, + networkCost, + maxSlippage, + swapRoutes, + path, + quoteType, + quotedTrade, + firstTokenFiatAmount, + secondTokenFiatAmount, + tokens, + setInitialTokens, + }; +}; diff --git a/src/react/hooks/token/useCsprFeeValidation.test.tsx b/src/react/hooks/token/useCsprFeeValidation.test.tsx new file mode 100644 index 0000000..32b1747 --- /dev/null +++ b/src/react/hooks/token/useCsprFeeValidation.test.tsx @@ -0,0 +1,108 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from '@testing-library/react'; + +import { useCsprFeeValidation } from './useCsprFeeValidation'; + +import { CSPR_NATIVE_TOKEN_ID } from '../../../domain/constants'; +import type { IDexToken } from '../../../domain/swap'; +import type { ISelectedTokensState, ITokenAmountsState } from './useTokenPairState'; + +const FEE = '2500000000'; // 2.5 CSPR +const AMOUNT = '10000000000'; // 10 CSPR + +const getRawBalance = (balance: string) => (tokenId: string) => + tokenId === CSPR_NATIVE_TOKEN_ID ? balance : '0'; + +const csprFirst = (raw: string) => ({ + selectedTokens: { first: { id: CSPR_NATIVE_TOKEN_ID } as IDexToken, second: null }, + tokenAmounts: { first: { formatted: '10', raw }, second: { formatted: '0', raw: '0' } }, +}); + +describe('useCsprFeeValidation', () => { + describe('no-pair flow (csprAmountInMotes)', () => { + it.each([ + { balance: '12500000000', expected: false, why: 'balance exactly covers amount + fee' }, + { balance: '99000000000', expected: false, why: 'balance comfortably covers amount + fee' }, + { balance: '12499999999', expected: true, why: 'balance is one mote short' }, + { balance: '0', expected: true, why: 'no balance at all' }, + ])('returns $expected when the $why', ({ balance, expected }) => { + const { result } = renderHook(() => + useCsprFeeValidation({ + getRawBalance: getRawBalance(balance), + feeInMotes: FEE, + csprAmountInMotes: AMOUNT, + isWalletConnected: true, + }), + ); + + expect(result.current()).toBe(expected); + }); + }); + + describe('swap flow (selectedTokens + tokenAmounts)', () => { + it('counts the CSPR being swapped, not just the fee, when CSPR is the input token', () => { + // Covers the fee twice over, but not the fee plus the 10 CSPR being swapped. + const { result } = renderHook(() => + useCsprFeeValidation({ + getRawBalance: getRawBalance('5000000000'), + feeInMotes: FEE, + isWalletConnected: true, + ...csprFirst(AMOUNT), + }), + ); + + expect(result.current()).toBe(true); + }); + + it('accepts the same balance once the swap amount is covered too', () => { + const { result } = renderHook(() => + useCsprFeeValidation({ + getRawBalance: getRawBalance('12500000000'), + feeInMotes: FEE, + isWalletConnected: true, + ...csprFirst(AMOUNT), + }), + ); + + expect(result.current()).toBe(false); + }); + + it('checks the fee alone when CSPR is not the input token', () => { + const selectedTokens = { + first: { id: 'tokA' } as IDexToken, + second: null, + } as ISelectedTokensState; + const tokenAmounts = { + first: { formatted: '10', raw: AMOUNT }, + second: { formatted: '0', raw: '0' }, + } as ITokenAmountsState; + + const { result } = renderHook(() => + useCsprFeeValidation({ + getRawBalance: getRawBalance(FEE), + feeInMotes: FEE, + isWalletConnected: true, + selectedTokens, + tokenAmounts, + }), + ); + + expect(result.current()).toBe(false); + }); + }); + + it('reports no shortfall while the wallet is disconnected', () => { + const { result } = renderHook(() => + useCsprFeeValidation({ + getRawBalance: getRawBalance('0'), + feeInMotes: FEE, + csprAmountInMotes: AMOUNT, + isWalletConnected: false, + }), + ); + + expect(result.current()).toBe(false); + }); +}); diff --git a/src/react/hooks/token/useCsprFeeValidation.ts b/src/react/hooks/token/useCsprFeeValidation.ts new file mode 100644 index 0000000..5a937b2 --- /dev/null +++ b/src/react/hooks/token/useCsprFeeValidation.ts @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; + +import type { ISelectedTokensState, ITokenAmountsState } from './useTokenPairState'; + +import { CSPR_NATIVE_TOKEN_ID } from '../../../domain/constants'; +import { hasEnoughCSPRBalance } from '../../../utils/amounts'; + +interface IUseCsprFeeValidationBase { + getRawBalance: (tokenId: string) => string; + feeInMotes: string; + isWalletConnected: boolean; +} + +/** + * The two modes are exclusive and one of them is mandatory. Supplying neither used to + * type-check and silently validate against an amount of `'0'` — gas only — so a user swapping + * their whole CSPR balance passed a check that never looked at the balance being spent. + */ +type IUseCsprFeeValidationParams = IUseCsprFeeValidationBase & + ( + | { + /** No token pair (e.g. wrap/unwrap): the CSPR being spent, in motes. */ + csprAmountInMotes: string; + selectedTokens?: never; + tokenAmounts?: never; + } + | { + csprAmountInMotes?: never; + /** Token pair (swap): the CSPR leg is read from the pair when it is the input token. */ + selectedTokens: ISelectedTokensState; + tokenAmounts: ITokenAmountsState; + } + ); + +export const useCsprFeeValidation = ({ + selectedTokens, + tokenAmounts, + getRawBalance, + feeInMotes, + csprAmountInMotes, + isWalletConnected, +}: IUseCsprFeeValidationParams) => { + return useCallback((): boolean => { + if (!isWalletConnected) return false; + + const csprRawBalance = getRawBalance(CSPR_NATIVE_TOKEN_ID); + + const amountInMotes = + csprAmountInMotes ?? + (selectedTokens?.first?.id === CSPR_NATIVE_TOKEN_ID + ? (tokenAmounts?.first?.raw ?? '0') + : '0'); + + return !hasEnoughCSPRBalance(csprRawBalance, amountInMotes, feeInMotes); + }, [ + isWalletConnected, + getRawBalance, + selectedTokens, + tokenAmounts, + feeInMotes, + csprAmountInMotes, + ]); +}; diff --git a/src/react/hooks/token/useTokenBalances.test.tsx b/src/react/hooks/token/useTokenBalances.test.tsx new file mode 100644 index 0000000..366b423 --- /dev/null +++ b/src/react/hooks/token/useTokenBalances.test.tsx @@ -0,0 +1,193 @@ +/** + * @jest-environment jsdom + */ +import { act, waitFor } from '@testing-library/react'; + +import { useTokenBalances } from './useTokenBalances'; + +import { + renderHookWithQueryClient, + stubSwapRepository, + stubTokensRepository, + TEST_PUBLIC_KEY, +} from '../../../__test-utils__/render-hook'; +import type { IDexToken } from '../../../domain/swap'; +import type { ICsprBalance, ITokenWithFiatBalance } from '../../../domain/tokens'; + +const makeDexToken = (packageHash: string): IDexToken => + ({ id: packageHash, packageHash, decimals: 9 }) as IDexToken; + +const makeHeldToken = ( + contractPackageHash: string, + balance: string, + decimals = 9, +): ITokenWithFiatBalance => ({ contractPackageHash, balance, decimals }) as ITokenWithFiatBalance; + +const makeCsprBalance = (over: Partial = {}): ICsprBalance => + ({ + totalBalance: '900000000000', + liquidBalance: '1000000000', + delegatedBalance: '899000000000', + ...over, + }) as ICsprBalance; + +// Built once per test and reused across re-renders: `refetchCsprBalance`'s identity depends on +// `tokensRepository`, so a fresh object on every render would re-trigger the reset effect forever. +const csprOnlyDeps = ( + getCsprBalance: jest.Mock, + activePublicKey: string | null = TEST_PUBLIC_KEY, +) => ({ + network: 'mainnet' as const, + activePublicKey, + tokensRepository: stubTokensRepository({ + getCsprBalance, + getTokens: jest.fn().mockResolvedValue([]), + }), + swapRepository: stubSwapRepository({ getDexTokens: jest.fn().mockResolvedValue([]) }), +}); + +describe('useTokenBalances', () => { + it('reports the spendable CSPR balance, not the total that includes stake', async () => { + const getCsprBalance = jest.fn().mockResolvedValue(makeCsprBalance()); + const deps = csprOnlyDeps(getCsprBalance); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getRawBalance('cspr')).toBe('1000000000')); + expect(getCsprBalance).toHaveBeenCalledWith({ + network: 'mainnet', + publicKey: TEST_PUBLIC_KEY, + }); + }); + + it('formats the CSPR balance against CSPR decimals', async () => { + const getCsprBalance = jest + .fn() + .mockResolvedValue(makeCsprBalance({ liquidBalance: '2500000000' })); + const deps = csprOnlyDeps(getCsprBalance); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getFormattedBalance('cspr')).toBe('2.5')); + }); + + it('keys CEP-18 balances by contract package hash and formats them by token decimals', async () => { + const getTokens = jest + .fn() + .mockResolvedValue([makeHeldToken('cph-1', '1500000', 6), makeHeldToken('cph-2', '0')]); + const deps = { + network: 'mainnet' as const, + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue(makeCsprBalance()), + getTokens, + }), + swapRepository: stubSwapRepository({ + getDexTokens: jest.fn().mockResolvedValue([makeDexToken('cph-1'), makeDexToken('cph-2')]), + }), + }; + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getRawBalance('cph-1')).toBe('1500000')); + expect(result.current.getFormattedBalance('cph-1')).toBe('1.5'); + expect(result.current.getRawBalance('cph-2')).toBe('0'); + }); + + // The parameter exists for deep links to tokens the trade API does not list; without it the + // balance reads '0', isAmountExceedsBalance is true for any amount, and the swap never enables. + it('resolves the balance of a hash supplied only through additionalContractPackageHashes', async () => { + const getTokens = jest.fn().mockResolvedValue([makeHeldToken('cph-unlisted', '4200000', 6)]); + const deps = { + network: 'mainnet' as const, + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue(makeCsprBalance()), + getTokens, + }), + swapRepository: stubSwapRepository({ + getDexTokens: jest.fn().mockResolvedValue([makeDexToken('cph-listed')]), + }), + additionalContractPackageHashes: ['cph-unlisted'], + }; + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getRawBalance('cph-unlisted')).toBe('4200000')); + expect(getTokens).toHaveBeenCalledWith( + expect.objectContaining({ + contractPackageHashes: expect.arrayContaining(['cph-listed', 'cph-unlisted']), + }), + ); + }); + + it('does not duplicate a hash that is both listed and supplied additionally', async () => { + const getTokens = jest.fn().mockResolvedValue([makeHeldToken('cph-1', '1', 6)]); + const deps = { + network: 'mainnet' as const, + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue(makeCsprBalance()), + getTokens, + }), + swapRepository: stubSwapRepository({ + getDexTokens: jest.fn().mockResolvedValue([makeDexToken('cph-1')]), + }), + additionalContractPackageHashes: ['cph-1'], + }; + + renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(getTokens).toHaveBeenCalled()); + expect(getTokens.mock.calls[0][0].contractPackageHashes).toEqual(['cph-1']); + }); + + it('reports "0" for a token that was never fetched', async () => { + const getCsprBalance = jest.fn().mockResolvedValue(makeCsprBalance()); + const deps = csprOnlyDeps(getCsprBalance); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getRawBalance('cspr')).toBe('1000000000')); + expect(result.current.getRawBalance('never-fetched')).toBe('0'); + expect(result.current.getFormattedBalance('never-fetched')).toBe('0'); + }); + + it('leaves balances empty and skips the request when no account is connected', async () => { + const getCsprBalance = jest.fn(); + const deps = csprOnlyDeps(getCsprBalance, null); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.tokenBalances.raw).toEqual({})); + expect(getCsprBalance).not.toHaveBeenCalled(); + }); + + it('re-reads the CSPR balance when asked to refetch', async () => { + const getCsprBalance = jest + .fn() + .mockResolvedValueOnce(makeCsprBalance({ liquidBalance: '1000000000' })) + .mockResolvedValueOnce(makeCsprBalance({ liquidBalance: '7000000000' })); + const deps = csprOnlyDeps(getCsprBalance); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(result.current.getRawBalance('cspr')).toBe('1000000000')); + + await act(async () => { + await result.current.refetchCsprBalance(); + }); + + expect(result.current.getRawBalance('cspr')).toBe('7000000000'); + }); + + it('survives a failing CSPR read without rejecting into the caller', async () => { + const getCsprBalance = jest.fn().mockRejectedValue(new Error('api down')); + const deps = csprOnlyDeps(getCsprBalance); + + const { result } = renderHookWithQueryClient(() => useTokenBalances(deps)); + + await waitFor(() => expect(getCsprBalance).toHaveBeenCalled()); + expect(result.current.getRawBalance('cspr')).toBe('0'); + }); +}); diff --git a/src/react/hooks/token/useTokenBalances.ts b/src/react/hooks/token/useTokenBalances.ts new file mode 100644 index 0000000..a7124da --- /dev/null +++ b/src/react/hooks/token/useTokenBalances.ts @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useFetchAccountTokenOwnership } from '../api/useFetchAccountTokenOwnership'; +import { useFetchDexTokens } from '../api/useFetchDexTokens'; + +import { CSPR_DECIMALS, CSPR_NATIVE_TOKEN_ID } from '../../../domain/constants'; +import { getDecimalTokenBalance } from '../../../utils/common'; +import type { ISwapDependencies } from '../../types'; + +export interface ITokenBalances { + formatted: Record; + raw: Record; +} + +export interface IUseTokenBalancesParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'tokensRepository' | 'swapRepository' +> { + additionalContractPackageHashes?: string[]; +} + +interface IUseTokenBalancesReturn { + tokenBalances: ITokenBalances; + getFormattedBalance: (tokenId: string) => string; + getRawBalance: (tokenId: string) => string; + resetBalances: () => void; + refetchCsprBalance: () => Promise; + refetchTokenBalances: () => Promise; +} + +export const useTokenBalances = ({ + network, + activePublicKey, + tokensRepository, + swapRepository, + additionalContractPackageHashes, +}: IUseTokenBalancesParams): IUseTokenBalancesReturn => { + const [tokenBalances, setTokenBalances] = useState({ + formatted: {}, + raw: {}, + }); + + const { data: tokens } = useFetchDexTokens({ network, swapRepository }); + + const contractPackageHashes = useMemo( + () => [ + ...(tokens ?? []) + .filter(token => token.id !== CSPR_NATIVE_TOKEN_ID && token.packageHash !== '') + .map(token => token.packageHash), + ...(additionalContractPackageHashes ?? []).filter( + h => !(tokens ?? []).some(t => t.packageHash === h), + ), + ], + [tokens, additionalContractPackageHashes], + ); + + const { data: ownershipData, refetch: refetchOwnership } = useFetchAccountTokenOwnership({ + network, + activePublicKey, + tokensRepository, + contractPackageHashes, + enabled: contractPackageHashes.length > 0, + }); + + const updateCSPRBalance = useCallback((rawBalance: string) => { + setTokenBalances(prev => ({ + formatted: { + ...prev.formatted, + cspr: getDecimalTokenBalance(rawBalance, CSPR_DECIMALS, '0'), + }, + raw: { + ...prev.raw, + cspr: rawBalance, + }, + })); + }, []); + + // Pull-based: the library exposes only `activePublicKey`, not a live account object, so the + // CSPR balance has to be fetched rather than pushed in from a wallet context. + // + // `liquidBalance`, not `totalBalance`: staked and undelegating motes cannot be spent, and + // offering them as swappable would build transactions the chain rejects. + const refetchCsprBalance = useCallback(async () => { + if (!activePublicKey) { + return; + } + + const { liquidBalance } = await tokensRepository.getCsprBalance({ + network, + publicKey: activePublicKey, + }); + + updateCSPRBalance(liquidBalance); + }, [network, activePublicKey, tokensRepository, updateCSPRBalance]); + + useEffect(() => { + setTokenBalances({ + formatted: {}, + raw: {}, + }); + + if (!activePublicKey) { + return; + } + + refetchCsprBalance().catch(() => { + // best-effort background refresh; callers can retry via the returned refetchCsprBalance + }); + }, [activePublicKey, refetchCsprBalance]); + + useEffect(() => { + if (ownershipData === undefined || ownershipData.length === 0) { + return; + } + + const nextRaw: Record = {}; + const nextFormatted: Record = {}; + + ownershipData.forEach(token => { + const rawBalance = token.balance || '0'; + + nextRaw[token.contractPackageHash] = rawBalance; + nextFormatted[token.contractPackageHash] = getDecimalTokenBalance( + rawBalance, + token.decimals, + '0', + ); + }); + + setTokenBalances(prev => ({ + formatted: { + ...prev.formatted, + ...nextFormatted, + }, + raw: { + ...prev.raw, + ...nextRaw, + }, + })); + }, [ownershipData]); + + const getFormattedBalance = useCallback( + (tokenId: string): string => { + const balance = tokenBalances.formatted[tokenId]; + + if (balance === undefined) { + return '0'; + } + + return balance; + }, + [tokenBalances.formatted], + ); + + const getRawBalance = useCallback( + (tokenId: string): string => { + return tokenBalances.raw[tokenId] || '0'; + }, + [tokenBalances.raw], + ); + + const resetBalances = useCallback(() => { + setTokenBalances({ + formatted: {}, + raw: {}, + }); + + refetchCsprBalance().catch(() => { + // best-effort background refresh; callers can retry via the returned refetchCsprBalance + }); + }, [refetchCsprBalance]); + + const refetchTokenBalances = useCallback(async () => { + await refetchOwnership(); + }, [refetchOwnership]); + + return { + tokenBalances, + getFormattedBalance, + getRawBalance, + resetBalances, + refetchCsprBalance, + refetchTokenBalances, + }; +}; diff --git a/src/react/hooks/token/useTokenPairBalances.ts b/src/react/hooks/token/useTokenPairBalances.ts new file mode 100644 index 0000000..38aeaec --- /dev/null +++ b/src/react/hooks/token/useTokenPairBalances.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; + +import type { ISelectedTokensState, ITokenAmountsState } from './useTokenPairState'; + +import { doesAmountExceedBalance } from '../../../utils/amounts'; +import type { TokenPosition } from '../../../utils/swap'; + +interface IUseTokenPairBalancesParams { + selectedTokens: ISelectedTokensState; + tokenAmounts: ITokenAmountsState; + getFormattedBalance: (tokenId: string) => string; + getRawBalance: (tokenId: string) => string; + isWalletConnected: boolean; +} + +export const useTokenPairBalances = ({ + selectedTokens, + tokenAmounts, + getFormattedBalance, + getRawBalance, + isWalletConnected, +}: IUseTokenPairBalancesParams) => { + const getTokenBalance = useCallback( + (position: TokenPosition): string => { + const token = selectedTokens[position]; + + if (!token) return '0'; + + return getFormattedBalance(token.id); + }, + [selectedTokens, getFormattedBalance], + ); + + const getRawTokenBalance = useCallback( + (position: TokenPosition): string => { + const token = selectedTokens[position]; + + if (!token) return '0'; + + return getRawBalance(token.id); + }, + [selectedTokens, getRawBalance], + ); + + const isAmountExceedsBalance = useCallback( + (position: TokenPosition): boolean => { + if (!isWalletConnected) return false; + + const token = selectedTokens[position]; + const amountRaw = tokenAmounts[position].raw; + + if (!token) return false; + + const balanceRaw = getRawTokenBalance(position); + + return doesAmountExceedBalance(amountRaw, balanceRaw); + }, + [isWalletConnected, selectedTokens, tokenAmounts, getRawTokenBalance], + ); + + return { + getTokenBalance, + getRawTokenBalance, + isAmountExceedsBalance, + }; +}; diff --git a/src/react/hooks/token/useTokenPairFiatAmounts.ts b/src/react/hooks/token/useTokenPairFiatAmounts.ts new file mode 100644 index 0000000..7ee511b --- /dev/null +++ b/src/react/hooks/token/useTokenPairFiatAmounts.ts @@ -0,0 +1,51 @@ +import { useFetchCsprFiatRates } from '../api/useFetchCsprFiatRates'; + +import { USD_CURRENCY_CODE } from '../../../domain/constants'; +import type { IDexToken } from '../../../domain/swap'; +import { calculateTokenFiatAmount } from '../../../utils/swap'; +import type { ISwapDependencies } from '../../types'; + +type TokenLike = Pick; + +export interface IUseTokenPairFiatAmountsParams extends Pick< + ISwapDependencies, + 'network' | 'tokensRepository' +> { + firstToken: TokenLike | null; + secondToken: TokenLike | null; + firstTokenAmount: string; + secondTokenAmount: string; +} + +export const useTokenPairFiatAmounts = ({ + network, + tokensRepository, + firstToken, + secondToken, + firstTokenAmount, + secondTokenAmount, +}: IUseTokenPairFiatAmountsParams) => { + const { csprFiatRates } = useFetchCsprFiatRates({ network, tokensRepository }); + + const firstTokenFiatAmount = calculateTokenFiatAmount( + firstToken as IDexToken | null, + firstTokenAmount, + USD_CURRENCY_CODE, + firstToken?.fiatRates, + csprFiatRates, + ); + + const secondTokenFiatAmount = calculateTokenFiatAmount( + secondToken as IDexToken | null, + secondTokenAmount, + USD_CURRENCY_CODE, + secondToken?.fiatRates, + csprFiatRates, + ); + + return { + firstTokenFiatAmount, + secondTokenFiatAmount, + csprFiatRates, + }; +}; diff --git a/src/react/hooks/token/useTokenPairState.ts b/src/react/hooks/token/useTokenPairState.ts new file mode 100644 index 0000000..93703fd --- /dev/null +++ b/src/react/hooks/token/useTokenPairState.ts @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useDebounce } from '../ui/useDebounce'; +import { useModalState } from '../ui/useModalState'; + +import { CSPR_NATIVE_TOKEN_ID } from '../../../domain/constants'; +import type { IDexToken } from '../../../domain/swap'; +import type { TokenPosition } from '../../../utils/swap'; + +export interface ISelectedTokensState { + first: IDexToken | null; + second: IDexToken | null; +} + +export interface ITokenAmountsState { + first: { + formatted: string; + raw: string; + }; + second: { + formatted: string; + raw: string; + }; +} + +interface IUseTokenPairStateParams { + tokens?: IDexToken[]; + defaultTokenId?: string; +} + +export const useTokenPairState = ({ + tokens, + defaultTokenId = CSPR_NATIVE_TOKEN_ID, +}: IUseTokenPairStateParams = {}) => { + const tokenSelectorModal = useModalState(false); + const reviewModal = useModalState(false); + + const [selectedTokens, setSelectedTokens] = useState({ + first: null, + second: null, + }); + + const [tokenAmounts, setTokenAmounts] = useState({ + first: { formatted: '0', raw: '0' }, + second: { formatted: '0', raw: '0' }, + }); + + const [activeTokenPosition, setActiveTokenPosition] = useState('first'); + const hasBothTokens = Boolean(selectedTokens.first && selectedTokens.second); + + const hasSetDefaultTokenRef = useRef(false); + + const debouncedTokenAmounts = useDebounce(tokenAmounts, 300); + + const customTokenHashes = useMemo(() => { + const listedHashes = new Set((tokens ?? []).map(t => t.packageHash)); + + return [selectedTokens.first, selectedTokens.second] + .filter((t): t is IDexToken => !!t && !listedHashes.has(t.packageHash)) + .map(t => t.packageHash); + }, [tokens, selectedTokens.first, selectedTokens.second]); + + const tokensWithAmounts = useMemo(() => { + return { + first: selectedTokens.first + ? { + ...selectedTokens.first, + amountFormatted: debouncedTokenAmounts.first.formatted, + amountRaw: debouncedTokenAmounts.first.raw, + } + : null, + second: selectedTokens.second + ? { + ...selectedTokens.second, + amountFormatted: debouncedTokenAmounts.second.formatted, + amountRaw: debouncedTokenAmounts.second.raw, + } + : null, + }; + }, [selectedTokens, debouncedTokenAmounts]); + + // Default to CSPR once, and only while nothing is selected — otherwise this would overwrite + // tokens set through `setInitialTokens` (e.g. deep-link preselection). + useEffect(() => { + if ( + tokens && + !hasSetDefaultTokenRef.current && + !selectedTokens.first && + !selectedTokens.second + ) { + const defaultToken = tokens.find(token => token.id === defaultTokenId); + + if (defaultToken) { + setSelectedTokens({ + first: defaultToken, + second: null, + }); + hasSetDefaultTokenRef.current = true; + } + } + }, [tokens, selectedTokens.first, selectedTokens.second, defaultTokenId]); + + const resetTokenAmounts = useCallback(() => { + setTokenAmounts({ + first: { formatted: '0', raw: '0' }, + second: { formatted: '0', raw: '0' }, + }); + }, []); + + const setInitialTokens = useCallback( + (firstToken: IDexToken, secondToken: IDexToken | null) => { + setSelectedTokens({ + first: firstToken, + second: secondToken, + }); + resetTokenAmounts(); + }, + [resetTokenAmounts], + ); + + const resetToDefaultTokens = useCallback(() => { + const defaultToken = tokens?.find(token => token.id === defaultTokenId) ?? null; + + setSelectedTokens({ + first: defaultToken, + second: null, + }); + resetTokenAmounts(); + }, [tokens, defaultTokenId, resetTokenAmounts]); + + return { + selectedTokens, + tokenAmounts, + activeTokenPosition, + hasBothTokens, + debouncedTokenAmounts, + tokensWithAmounts, + customTokenHashes, + + setSelectedTokens, + setTokenAmounts, + setActiveTokenPosition, + setInitialTokens, + resetTokenAmounts, + resetToDefaultTokens, + + tokenSelectorModal, + reviewModal, + }; +}; diff --git a/src/react/hooks/token/useTokenPreselection.ts b/src/react/hooks/token/useTokenPreselection.ts new file mode 100644 index 0000000..c61cba7 --- /dev/null +++ b/src/react/hooks/token/useTokenPreselection.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; + +import { useFetchToken } from '../api/useFetchToken'; + +import type { IDexToken } from '../../../domain/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseTokenPreselectionParams extends Pick< + ISwapDependencies, + 'network' | 'swapRepository' +> { + tokenInHash?: string; // deep-link token hashes; router/URL parsing is the consumer's job + tokenOutHash?: string; + tokens: IDexToken[]; + setInitialTokens: (tokens: { first: IDexToken | null; second: IDexToken | null }) => void; + setSelectedTokens: React.Dispatch< + React.SetStateAction<{ first: IDexToken | null; second: IDexToken | null }> + >; +} + +export const useTokenPreselection = ({ + network, + swapRepository, + tokenInHash = '', + tokenOutHash = '', + tokens, + setInitialTokens, + setSelectedTokens, +}: IUseTokenPreselectionParams): void => { + const hasSetRef = useRef(false); + + // `tokens` is a non-nullable array, so an empty list — still loading — is indistinguishable + // from a loaded list that does not contain the hash, and both send it to the custom fetch. + const tokenInInListed = tokens.some(t => t.packageHash === tokenInHash); + const tokenOutInListed = tokens.some(t => t.packageHash === tokenOutHash); + + const { token: customTokenIn } = useFetchToken({ + network, + swapRepository, + contractPackageHash: tokenInHash && !tokenInInListed ? tokenInHash : '', + }); + const { token: customTokenOut } = useFetchToken({ + network, + swapRepository, + contractPackageHash: tokenOutHash && !tokenOutInListed ? tokenOutHash : '', + }); + + useEffect(() => { + if (hasSetRef.current) return; + + const tokenIn = tokens.find(t => t.packageHash === tokenInHash) ?? customTokenIn ?? null; + const tokenOut = tokenOutHash + ? (tokens.find(t => t.packageHash === tokenOutHash) ?? customTokenOut ?? null) + : null; + + if (!tokenInHash) return; + if (!tokenIn) return; // still loading or not found — effect re-runs when customTokenIn resolves + + if (tokenOut && tokenIn.id !== tokenOut.id) { + setInitialTokens({ first: tokenIn, second: tokenOut }); + } else if (!tokenOutHash) { + setSelectedTokens({ first: tokenIn, second: null }); + } else if (!tokenOut) { + return; // tokenOut still loading — effect re-runs when customTokenOut resolves + } + + hasSetRef.current = true; + }, [ + tokens, + tokenInHash, + tokenOutHash, + customTokenIn, + customTokenOut, + setInitialTokens, + setSelectedTokens, + ]); +}; diff --git a/src/react/hooks/token/useTokenWarnings.ts b/src/react/hooks/token/useTokenWarnings.ts new file mode 100644 index 0000000..8469028 --- /dev/null +++ b/src/react/hooks/token/useTokenWarnings.ts @@ -0,0 +1,57 @@ +import type { ISelectedTokensState } from './useTokenPairState'; + +interface IUseTokenWarningsReturn { + unlistedTokens: NonNullable[]; + blacklistedTokens: NonNullable[]; + blacklistedMessage: string | null; + unlistedMessage: string | null; + hasUnlistedTokens: boolean; +} + +export const useTokenWarnings = ( + selectedTokens: ISelectedTokensState, + context: 'swap' | 'add-liquidity', +): IUseTokenWarningsReturn => { + const blacklistedTokens = [ + selectedTokens.first?.isBlacklisted === true ? selectedTokens.first : null, + selectedTokens.second?.isBlacklisted === true ? selectedTokens.second : null, + ].filter(Boolean) as NonNullable[]; + + const unlistedTokens = [ + selectedTokens.first && + !selectedTokens.first.isWhitelisted && + !selectedTokens.first.isBlacklisted + ? selectedTokens.first + : null, + selectedTokens.second && + !selectedTokens.second.isWhitelisted && + !selectedTokens.second.isBlacklisted + ? selectedTokens.second + : null, + ].filter(Boolean) as NonNullable[]; + + const action = context === 'swap' ? 'trading' : 'adding liquidity'; + const pairLabel = context === 'swap' ? 'trading pair' : 'token pair'; + + const blacklistedMessage = + blacklistedTokens.length === 2 + ? `${blacklistedTokens[0].symbol} and ${blacklistedTokens[1].symbol} are not available for ${action}. Please select a different ${pairLabel}.` + : blacklistedTokens.length === 1 + ? `Token ${blacklistedTokens[0].symbol} is not available for ${action}. Please select a different ${pairLabel}.` + : null; + + const unlistedMessage = + unlistedTokens.length > 0 + ? context === 'swap' + ? 'You are trading an unlisted token. Verify the contract address carefully, and proceed only if you understand the risks.' + : 'You are adding liquidity to a pool with an unlisted token. Please confirm the token details before depositing.' + : null; + + return { + unlistedTokens, + blacklistedTokens, + blacklistedMessage, + unlistedMessage, + hasUnlistedTokens: unlistedTokens.length > 0, + }; +}; diff --git a/src/react/hooks/ui/useDebounce.ts b/src/react/hooks/ui/useDebounce.ts new file mode 100644 index 0000000..1d492af --- /dev/null +++ b/src/react/hooks/ui/useDebounce.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(handler); + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/react/hooks/ui/useModalState.ts b/src/react/hooks/ui/useModalState.ts new file mode 100644 index 0000000..5c03163 --- /dev/null +++ b/src/react/hooks/ui/useModalState.ts @@ -0,0 +1,31 @@ +import { useCallback, useState } from 'react'; + +export interface IUseModalStateReturn { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +export const useModalState = (initialState = false): IUseModalStateReturn => { + const [isOpen, setIsOpen] = useState(initialState); + + const open = useCallback(() => { + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const toggle = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + + return { + isOpen, + open, + close, + toggle, + }; +}; diff --git a/src/react/hooks/wrap/useReviewWrap.test.tsx b/src/react/hooks/wrap/useReviewWrap.test.tsx new file mode 100644 index 0000000..ec1329a --- /dev/null +++ b/src/react/hooks/wrap/useReviewWrap.test.tsx @@ -0,0 +1,238 @@ +/** + * @jest-environment jsdom + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { ReplaySubject } from 'rxjs'; + +import { useReviewWrap } from './useReviewWrap'; + +import { TEST_PUBLIC_KEY } from '../../../__test-utils__/render-hook'; +import type { IWrapFlowHandle, IWrapFlowResult, WrapFlowEvent } from '../../../domain/flows'; +import type { IDexTokenWithAmount } from '../../../domain/swap'; + +const token = (id: string): IDexTokenWithAmount => + ({ id, packageHash: id, decimals: 9, amountRaw: '1000000000', amountFormatted: '1' }) as never; + +/** Test double for a flow runner; `events$` replays like the real one. */ +const makeRunner = (publicKey = TEST_PUBLIC_KEY) => { + const cancel = jest.fn(); + const flows: Array<{ + events$: ReplaySubject; + settle: (result: IWrapFlowResult) => void; + handle: IWrapFlowHandle; + }> = []; + + const start = jest.fn((): IWrapFlowHandle => { + const events$ = new ReplaySubject(Infinity); + let settle!: (result: IWrapFlowResult) => void; + const done = new Promise(resolve => { + settle = resolve; + }); + const handle: IWrapFlowHandle = { + id: `flow-${flows.length + 1}`, + events$: events$.asObservable(), + done, + cancel, + }; + + flows.push({ events$, settle, handle }); + + return handle; + }); + + const current = () => flows[flows.length - 1]; + + return { + cancel, + start, + publicKey, + getActive: jest.fn(() => current()?.handle ?? null), + get events$() { + return current().events$; + }, + settle: (result: IWrapFlowResult = { status: 'success' }) => current().settle(result), + }; +}; + +const setup = (runner: ReturnType, isOpen = true) => + renderHook( + (props: { isOpen: boolean }) => + useReviewWrap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + wrapFlowRunner: runner as never, + direction: 'wrap', + sourceToken: token('cspr'), + isOpen: props.isOpen, + onWrapSuccess: jest.fn(), + onClose: jest.fn(), + }), + { initialProps: { isOpen } }, + ); + +describe('useReviewWrap', () => { + it('reaches the success step when the flow confirms the wrap', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + act(() => { + runner.events$.next({ type: 'wrap:signing' }); + runner.events$.next({ type: 'wrap:sent', hash: '0xw' }); + runner.events$.next({ + type: 'wrap:confirmed', + outcome: { hash: '0xw', status: 'success', blockHeight: 1 }, + }); + }); + + await waitFor(() => expect(result.current.step).toBe('success')); + expect(result.current.status).toBe('success'); + expect(result.current.transactionHash).toBe('0xw'); + }); + + it('surfaces a failure and returns to the confirm step', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + act(() => { + runner.events$.next({ type: 'wrap:signing' }); + runner.events$.next({ type: 'failed', error: new Error('nope') }); + }); + + await waitFor(() => expect(result.current.step).toBe('confirm')); + expect(result.current.error).toBe('nope'); + expect(result.current.isProcessing).toBe(false); + }); + + it('retries in place after a failure once the flow has settled', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + act(() => { + runner.events$.next({ type: 'failed', error: new Error('nope') }); + }); + + await waitFor(() => expect(result.current.step).toBe('confirm')); + + await act(async () => { + runner.settle({ status: 'failed', error: new Error('nope') }); + }); + + await act(async () => { + result.current.confirmWrap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(2); + }); + + it('retries after a success once the flow has settled', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + act(() => { + runner.events$.next({ + type: 'wrap:confirmed', + outcome: { hash: '0xw', status: 'success', blockHeight: 1 }, + }); + }); + + await waitFor(() => expect(result.current.step).toBe('success')); + + await act(async () => { + runner.settle(); + }); + + await act(async () => { + result.current.confirmWrap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(2); + }); + + it('refuses to start against a runner bound to a different account', async () => { + const runner = makeRunner('other-account'); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + expect(runner.start).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.error).toContain('runner-account-mismatch')); + }); + + it('never starts a second flow while one is already running', async () => { + const runner = makeRunner(); + const { result } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + result.current.confirmWrap(); + }); + + expect(runner.start).toHaveBeenCalledTimes(1); + }); + + it('does not cancel the flow when the modal closes', async () => { + const runner = makeRunner(); + const { result, rerender } = setup(runner); + + await act(async () => { + result.current.confirmWrap(); + }); + + rerender({ isOpen: false }); + + expect(runner.cancel).not.toHaveBeenCalled(); + }); + + it('calls onWrapSuccess exactly once when the wrap confirms', async () => { + const runner = makeRunner(); + const onWrapSuccess = jest.fn(); + + const { result } = renderHook(() => + useReviewWrap({ + network: 'testnet', + activePublicKey: TEST_PUBLIC_KEY, + wrapFlowRunner: runner as never, + direction: 'wrap', + sourceToken: token('cspr'), + isOpen: true, + onWrapSuccess, + onClose: jest.fn(), + }), + ); + + await act(async () => { + result.current.confirmWrap(); + }); + + act(() => { + runner.events$.next({ + type: 'wrap:confirmed', + outcome: { hash: '0xw', status: 'success', blockHeight: 1 }, + }); + runner.events$.next({ + type: 'wrap:confirmed', + outcome: { hash: '0xw', status: 'success', blockHeight: 1 }, + }); + }); + + await waitFor(() => expect(onWrapSuccess).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/react/hooks/wrap/useReviewWrap.ts b/src/react/hooks/wrap/useReviewWrap.ts new file mode 100644 index 0000000..77e981f --- /dev/null +++ b/src/react/hooks/wrap/useReviewWrap.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; + +import { FlowError, initialWrapFlowState, wrapFlowReducer } from '../../../domain/flows'; +import type { IStartWrapFlowParams, IWrapFlowHandle } from '../../../domain/flows'; +import type { WrapDirection } from '../../../domain/dex'; +import type { IDexTokenWithAmount } from '../../../domain/swap'; +import type { ISwapDependencies } from '../../types'; + +export interface IUseReviewWrapParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'wrapFlowRunner' +> { + direction: WrapDirection; + sourceToken: IDexTokenWithAmount; + isOpen: boolean; + onWrapSuccess: () => void; + onClose: () => void; +} + +/** + * Subscribes the review modal to a running wrap flow. Closing the modal (`isOpen: false`) + * unsubscribes but never cancels the handle, so a submitted wrap keeps running and reopening + * replays the flow's history. Only `handle.cancel()` stops a flow. + */ +export const useReviewWrap = ({ + activePublicKey, + wrapFlowRunner, + direction, + sourceToken, + isOpen, + onWrapSuccess, + onClose, +}: IUseReviewWrapParams) => { + const [handle, setHandle] = useState(null); + const [state, dispatch] = useReducer(wrapFlowReducer, initialWrapFlowState); + const succeededRef = useRef(false); + // Non-null while a flow is live. A ref, not state: `confirmWrap` can fire twice in one tick, + // before `setHandle` has re-rendered. + const handleRef = useRef(null); + // Held in a ref rather than a dependency: an inline callback would change identity every + // render, resubscribing and restarting the fold. + const onWrapSuccessRef = useRef(onWrapSuccess); + onWrapSuccessRef.current = onWrapSuccess; + + const releaseGuard = useCallback((settled: IWrapFlowHandle) => { + if (handleRef.current === settled) { + handleRef.current = null; + } + }, []); + + useEffect(() => { + // Unsubscribing while the surface is closed only stops the hook from applying events; the + // flow keeps running. `events$` is replayed, so reopening re-folds the whole history onto the + // state already folded — every reducer case overwrites, so that converges rather than doubles. + if (!handle || !isOpen) return; + + const subscription = handle.events$.subscribe({ + next: event => { + dispatch(event); + + if (event.type === 'wrap:confirmed' && !succeededRef.current) { + succeededRef.current = true; + onWrapSuccessRef.current(); + } + }, + // The flow never errors the stream, but a merged `ledgerEvents$` can; without a handler + // rxjs rethrows out of band and the surface stays on `confirm` with no reason. + error: (error: unknown) => dispatch({ type: 'failed', error }), + }); + + // Unsubscribe only — cancelling here would abandon a submitted wrap. + return () => subscription.unsubscribe(); + }, [handle, isOpen]); + + const confirmWrap = useCallback(() => { + if (handleRef.current || !wrapFlowRunner || !activePublicKey) return; + + if (wrapFlowRunner.publicKey !== activePublicKey) { + dispatch({ type: 'failed', error: new FlowError('runner-account-mismatch') }); + + return; + } + + const params: IStartWrapFlowParams = { + direction, + rawAmount: sourceToken.amountRaw, + }; + + const newHandle = wrapFlowRunner.start(params); + handleRef.current = newHandle; + setHandle(newHandle); + // Only `done` releases the guard; clearing it on unsubscribe would miss a flow that ended + // while the surface was closed. + const release = () => releaseGuard(newHandle); + newHandle.done.then(release, release); + }, [activePublicKey, direction, releaseGuard, sourceToken.amountRaw, wrapFlowRunner]); + + const handleCloseSuccessModal = useCallback(() => { + onClose(); + }, [onClose]); + + return { + step: state.step, + status: state.wrap.status, + error: state.wrap.error ?? null, + transactionHash: state.wrap.hash ?? null, + isProcessing: state.step === 'signing', + confirmWrap, + handleCloseSuccessModal, + ledgerEvent: state.ledgerEvent, + }; +}; diff --git a/src/react/hooks/wrap/useWrapTokens.test.tsx b/src/react/hooks/wrap/useWrapTokens.test.tsx new file mode 100644 index 0000000..cf465b8 --- /dev/null +++ b/src/react/hooks/wrap/useWrapTokens.test.tsx @@ -0,0 +1,146 @@ +/** + * @jest-environment jsdom + */ +import { act, waitFor } from '@testing-library/react'; + +import { useWrapTokens } from './useWrapTokens'; + +import { + renderHookWithQueryClient, + stubSwapRepository, + stubTokensRepository, + TEST_PUBLIC_KEY, +} from '../../../__test-utils__/render-hook'; +import { WrappedCsprContractPackageHash } from '../../../domain/constants'; +import type { IDexToken } from '../../../domain/swap'; +import type { ICsprBalance, ITokenWithFiatBalance } from '../../../domain/tokens'; + +const WCSPR = WrappedCsprContractPackageHash.mainnet; + +const makeDeps = ({ + liquidBalance = '100000000000', // 100 CSPR + wcsprBalance = '50000000000', // 50 WCSPR +}: { liquidBalance?: string; wcsprBalance?: string } = {}) => ({ + network: 'mainnet' as const, + activePublicKey: TEST_PUBLIC_KEY, + tokensRepository: stubTokensRepository({ + getCsprBalance: jest.fn().mockResolvedValue({ liquidBalance } as ICsprBalance), + getTokens: jest + .fn() + .mockResolvedValue([ + { contractPackageHash: WCSPR, balance: wcsprBalance, decimals: 9 } as ITokenWithFiatBalance, + ]), + }), + swapRepository: stubSwapRepository({ + getDexTokens: jest + .fn() + .mockResolvedValue([ + { id: 'cspr', packageHash: WCSPR, decimals: 9, symbol: 'CSPR' } as IDexToken, + ]), + }), +}); + +const render = (deps: ReturnType) => + renderHookWithQueryClient(() => useWrapTokens(deps)); + +describe('useWrapTokens', () => { + it('starts in the wrap direction and switches', async () => { + const { result } = render(makeDeps()); + + expect(result.current.direction).toBe('wrap'); + + await act(async () => { + result.current.switchDirection(); + }); + + expect(result.current.direction).toBe('unwrap'); + }); + + describe('CSPR fee validation', () => { + // Wrapping spends native CSPR; unwrapping burns WCSPR and needs CSPR only for the fee. + // Dropping the direction check would demand liquid CSPR equal to the WCSPR being unwrapped, + // so a user holding WCSPR and little CSPR could never unwrap. + it('counts the amount being wrapped against the CSPR balance', async () => { + const { result } = render(makeDeps({ liquidBalance: '10000000000' })); // 10 CSPR + + await act(async () => { + result.current.updateAmount('9'); + }); + + // 9 CSPR to wrap + 5 CSPR fee > 10 CSPR held. + await waitFor(() => expect(result.current.isInsufficientCsprForFees()).toBe(true)); + }); + + it('counts only the fee when unwrapping', async () => { + const { result } = render( + makeDeps({ liquidBalance: '10000000000', wcsprBalance: '50000000000' }), + ); + + await act(async () => { + result.current.switchDirection(); + }); + await act(async () => { + result.current.updateAmount('9'); + }); + + // The same 9 against the same 10 CSPR, but the 9 comes out of WCSPR. + await waitFor(() => expect(result.current.isInsufficientCsprForFees()).toBe(false)); + }); + }); + + describe('source balance', () => { + it('reads the native CSPR balance when wrapping', async () => { + const { result } = render(makeDeps()); + + await waitFor(() => expect(result.current.getTokenBalance('first')).toBe('100')); + }); + + // Resolving this against the ownership-keyed map instead of the WCSPR fetch returns '0', + // and isAmountExceedsBalance then blocks every unwrap. + it('reads the WCSPR balance when unwrapping', async () => { + const { result } = render(makeDeps()); + + await act(async () => { + result.current.switchDirection(); + }); + + await waitFor(() => expect(result.current.getTokenBalance('first')).toBe('50')); + }); + + it('does not report an unwrap within the WCSPR balance as exceeding it', async () => { + const { result } = render(makeDeps()); + + await act(async () => { + result.current.switchDirection(); + }); + await act(async () => { + result.current.updateAmount('40'); + }); + + await waitFor(() => expect(result.current.isAmountExceedsBalance('first')).toBe(false)); + }); + + it('reports an unwrap beyond the WCSPR balance as exceeding it', async () => { + const { result } = render(makeDeps()); + + await act(async () => { + result.current.switchDirection(); + }); + await act(async () => { + result.current.updateAmount('60'); + }); + + await waitFor(() => expect(result.current.isAmountExceedsBalance('first')).toBe(true)); + }); + }); + + it('derives sourceRawAmount from the typed amount, not from a balance', async () => { + const { result } = render(makeDeps()); + + await act(async () => { + result.current.updateAmount('1.5'); + }); + + expect(result.current.sourceRawAmount).toBe('1500000000'); + }); +}); diff --git a/src/react/hooks/wrap/useWrapTokens.ts b/src/react/hooks/wrap/useWrapTokens.ts new file mode 100644 index 0000000..8d1c7b8 --- /dev/null +++ b/src/react/hooks/wrap/useWrapTokens.ts @@ -0,0 +1,233 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { useFetchDexTokens } from '../api/useFetchDexTokens'; +import { useFetchTokenBalance } from '../api/useFetchTokenBalance'; +import { useCsprFeeValidation } from '../token/useCsprFeeValidation'; +import { useTokenBalances } from '../token/useTokenBalances'; +import { useTokenPairBalances } from '../token/useTokenPairBalances'; +import { useTokenPairFiatAmounts } from '../token/useTokenPairFiatAmounts'; +import { useModalState } from '../ui/useModalState'; + +import { + CSPR_COIN, + CSPR_DECIMALS, + CSPR_NATIVE_TOKEN_ID, + DEX_PAYMENT_AMOUNT, + WrappedCsprContractPackageHash, +} from '../../../domain/constants'; +import type { WrapDirection } from '../../../domain/dex'; +import type { IDexToken } from '../../../domain/swap'; +import { isAmountInputValid, isPositiveAmount } from '../../../utils/amounts'; +import { getBlockchainAmount, getDecimalTokenBalance } from '../../../utils/common'; +import type { ISwapDependencies } from '../../types'; + +const buildNativeCsprToken = (csprFromList: IDexToken | undefined): IDexToken => ({ + id: CSPR_NATIVE_TOKEN_ID, + name: CSPR_COIN.name, + symbol: CSPR_COIN.symbol, + icon: csprFromList?.icon ?? null, + decimals: CSPR_DECIMALS, + packageHash: '', + isWhitelisted: true, + isBlacklisted: false, + fiatRates: csprFromList?.fiatRates ?? null, + totalValueLocked: null, + volume24h: null, +}); + +const buildWcsprToken = ( + csprFromList: IDexToken | undefined, + wrappedCsprPackageHash: string, +): IDexToken => ({ + id: wrappedCsprPackageHash, + name: 'Wrapped Casper', + symbol: 'WCSPR', + icon: csprFromList?.icon ?? null, + decimals: CSPR_DECIMALS, + packageHash: wrappedCsprPackageHash, + isWhitelisted: true, + isBlacklisted: false, + fiatRates: csprFromList?.fiatRates ?? null, + totalValueLocked: null, + volume24h: null, +}); + +export interface IUseWrapTokensParams extends Pick< + ISwapDependencies, + 'network' | 'activePublicKey' | 'swapRepository' | 'tokensRepository' +> {} + +/** + * WCSPR page orchestrator: wrap/unwrap direction, amount, both legs' balances and the review + * modal. + */ +export const useWrapTokens = ({ + network, + activePublicKey, + swapRepository, + tokensRepository, +}: IUseWrapTokensParams) => { + const isWalletConnected = Boolean(activePublicKey); + + const wrappedCsprPackageHash = WrappedCsprContractPackageHash[network]; + + const { data: tokens } = useFetchDexTokens({ network, swapRepository }); + + const [direction, setDirection] = useState('wrap'); + const [amount, setAmount] = useState('0'); + + const reviewModal = useModalState(false); + + // The token list maps the WCSPR API record to a virtual CSPR token (id='cspr'), so both legs + // are rebuilt here to let the form target native CSPR and the real WCSPR contract separately. + const csprFromList = useMemo( + () => tokens?.find(token => token.id === CSPR_NATIVE_TOKEN_ID), + [tokens], + ); + const csprToken = useMemo(() => buildNativeCsprToken(csprFromList), [csprFromList]); + const wcsprToken = useMemo( + () => buildWcsprToken(csprFromList, wrappedCsprPackageHash), + [csprFromList, wrappedCsprPackageHash], + ); + + const sourceToken = direction === 'wrap' ? csprToken : wcsprToken; + const destinationToken = direction === 'wrap' ? wcsprToken : csprToken; + + const sourceRawAmount = useMemo(() => getBlockchainAmount(amount, CSPR_DECIMALS, '0'), [amount]); + + const { firstTokenFiatAmount: sourceTokenFiatAmount } = useTokenPairFiatAmounts({ + network, + tokensRepository, + firstToken: sourceToken, + secondToken: destinationToken, + firstTokenAmount: amount, + secondTokenAmount: amount, + }); + + const { getFormattedBalance, getRawBalance, refetchCsprBalance } = useTokenBalances({ + network, + activePublicKey, + tokensRepository, + swapRepository, + }); + + // Wrapping and unwrapping both move this balance, so the WCSPR leg gets its own fetch and a + // refetch handle to run right after the transaction. + const { data: wcsprBalance, refetch: refetchWcsprBalance } = useFetchTokenBalance({ + network, + activePublicKey, + tokensRepository, + contractPackageHash: wrappedCsprPackageHash, + enabled: isWalletConnected, + }); + + const wcsprRawBalance = useMemo(() => wcsprBalance || '0', [wcsprBalance]); + + const wcsprFormattedBalance = useMemo( + () => getDecimalTokenBalance(wcsprRawBalance, CSPR_DECIMALS, '0'), + [wcsprRawBalance], + ); + + const getFormattedBalanceById = useCallback( + (tokenId: string): string => + tokenId === wrappedCsprPackageHash ? wcsprFormattedBalance : getFormattedBalance(tokenId), + [getFormattedBalance, wcsprFormattedBalance, wrappedCsprPackageHash], + ); + + const getRawBalanceById = useCallback( + (tokenId: string): string => + tokenId === wrappedCsprPackageHash ? wcsprRawBalance : getRawBalance(tokenId), + [getRawBalance, wcsprRawBalance, wrappedCsprPackageHash], + ); + + const selectedTokens = useMemo( + () => ({ first: sourceToken, second: destinationToken }), + [sourceToken, destinationToken], + ); + + const tokenAmounts = useMemo( + () => ({ + first: { formatted: amount, raw: sourceRawAmount }, + second: { formatted: amount, raw: sourceRawAmount }, + }), + [amount, sourceRawAmount], + ); + + const { getTokenBalance, getRawTokenBalance, isAmountExceedsBalance } = useTokenPairBalances({ + selectedTokens, + tokenAmounts, + getFormattedBalance: getFormattedBalanceById, + getRawBalance: getRawBalanceById, + isWalletConnected, + }); + + const feeInMotes = useMemo( + () => (direction === 'wrap' ? DEX_PAYMENT_AMOUNT.wrap : DEX_PAYMENT_AMOUNT.unwrap), + [direction], + ); + + const isInsufficientCsprForFees = useCsprFeeValidation({ + getRawBalance: getRawBalanceById, + feeInMotes, + // Only wrapping spends native CSPR (the amount being wrapped); unwrapping spends CSPR only + // for the fee. + csprAmountInMotes: direction === 'wrap' ? sourceRawAmount : '0', + isWalletConnected, + }); + + const isAmountEntered = isPositiveAmount(amount); + + const isFormValid = Boolean( + isAmountEntered && !isAmountExceedsBalance('first') && !isInsufficientCsprForFees(), + ); + + const updateAmount = useCallback((value: string) => { + if (value !== '' && !isAmountInputValid(value, CSPR_DECIMALS)) { + return; + } + + setAmount(value === '' ? '0' : value); + }, []); + + const switchDirection = useCallback(() => { + setDirection(prev => (prev === 'wrap' ? 'unwrap' : 'wrap')); + setAmount('0'); + }, []); + + const resetAmount = useCallback(() => { + setAmount('0'); + }, []); + + const onWrapSuccess = useCallback(() => { + resetAmount(); + + refetchCsprBalance().catch(() => { + // best-effort background refresh; consumers can retry via the returned onWrapSuccess + }); + // No catch: this is a react-query `refetch()`, which swallows its own rejection + // (QueryObserver only rethrows under `throwOnError`), so there is nothing here to catch. + refetchWcsprBalance(); + }, [resetAmount, refetchCsprBalance, refetchWcsprBalance]); + + return { + direction, + amount, + sourceToken, + destinationToken, + sourceRawAmount, + sourceTokenFiatAmount, + isFormValid, + isAmountEntered, + isReviewModalOpen: reviewModal.isOpen, + openReviewModal: reviewModal.open, + closeReviewModal: reviewModal.close, + updateAmount, + switchDirection, + resetAmount, + onWrapSuccess, + getTokenBalance, + getRawTokenBalance, + isAmountExceedsBalance, + isInsufficientCsprForFees, + }; +}; diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..838645b --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,21 @@ +export * from './types'; +export * from './hooks/ui/useDebounce'; +export * from './hooks/ui/useModalState'; +export * from './hooks/api/useFetchSwapQuote'; +export * from './hooks/api/useFetchDexTokens'; +export * from './hooks/api/useFetchCsprFiatRates'; +export * from './hooks/api/useFetchAccountTokenOwnership'; +export * from './hooks/api/useFetchToken'; +export * from './hooks/api/useFetchTokenBalance'; +export * from './hooks/token/useTokenPairState'; +export * from './hooks/token/useTokenBalances'; +export * from './hooks/token/useTokenPairBalances'; +export * from './hooks/token/useCsprFeeValidation'; +export * from './hooks/token/useTokenPairFiatAmounts'; +export * from './hooks/token/useTokenWarnings'; +export * from './hooks/token/useTokenPreselection'; +export * from './hooks/swap/useSwapRouteTokens'; +export * from './hooks/swap/useReviewSwap'; +export * from './hooks/swap/useSwapTokens'; +export * from './hooks/wrap/useReviewWrap'; +export * from './hooks/wrap/useWrapTokens'; diff --git a/src/react/types.ts b/src/react/types.ts new file mode 100644 index 0000000..331937a --- /dev/null +++ b/src/react/types.ts @@ -0,0 +1,21 @@ +import type { CasperNetwork } from '../domain/common/common'; +import type { IDexContractRepository } from '../domain/dex'; +import type { ISwapFlowRunner, IWrapFlowRunner } from '../domain/flows'; +import type { ISwapRepository } from '../domain/swap'; +import type { ITokensRepository } from '../domain/tokens'; + +export type { TransactionStatus } from '../domain/flows'; + +/** Everything the React hooks need from the host app. Each hook takes only the subset it uses. */ +export interface ISwapDependencies { + swapRepository: ISwapRepository; + dexContractRepository: IDexContractRepository; + tokensRepository: ITokensRepository; + network: CasperNetwork; + /** Runs the swap flow (approve, then swap) for the connected account, or `null` when no wallet is connected. */ + swapFlowRunner: ISwapFlowRunner | null; + /** Runs the wrap/unwrap flow for the connected account, or `null` when no wallet is connected. */ + wrapFlowRunner: IWrapFlowRunner | null; + /** The connected account's public key, or `null` when no wallet is connected. */ + activePublicKey: string | null; +} diff --git a/src/sdk-free-modules.test.ts b/src/sdk-free-modules.test.ts index e2ad79f..640bc3e 100644 --- a/src/sdk-free-modules.test.ts +++ b/src/sdk-free-modules.test.ts @@ -2,16 +2,16 @@ import fs from 'fs'; import path from 'path'; /** - * Guards the invariant WALLET-1421 buys: the helpers a wallet client calls while rendering its - * home screen must not reach `casper-js-sdk`. + * Two static import-graph gates. * - * The SDK ships one prebuilt UMD bundle with no ESM build and no `sideEffects` flag, so a single - * value import of it links ~900 KB that no bundler can shake back out. Tree-shaking can hide a - * regression here (the host build may still drop it), which is precisely why this is checked - * statically instead of being left to a bundle measurement in another repo. + * The first: the helpers a wallet client calls while rendering its home screen must not reach + * `casper-js-sdk`. The SDK ships one prebuilt UMD bundle with no ESM build and no `sideEffects` + * flag, so a single value import links ~900 KB no bundler can shake back out. `import type` / + * `export type` are ignored — TypeScript and Babel both erase them. * - * `import type` / `export type` are ignored: TypeScript and Babel both erase them, so they cost - * nothing at runtime. + * The second: the optional Ledger packages. They are optional peers and this package ships raw + * TypeScript, so a consumer that skips them compiles our sources without them — there a type-only + * import fails just as hard as a value one, and the walk counts both. */ const REPO_ROOT = path.resolve(__dirname, '..'); @@ -28,6 +28,9 @@ const SDK_FREE_ENTRY_POINTS = [ // The data repositories a home screen renders from. `src/setup.ts` builds the signing // repositories too and links the SDK by design; this is the half that must not. 'src/setupData.ts', + // The React hook layer, documented in the README as deep-importable without the SDK: its + // repository dependencies arrive as injected interfaces. + 'src/react/index.ts', ]; interface Import { @@ -66,8 +69,12 @@ const resolveRelative = (fromFile: string, specifier: string): string | null => return null; }; -/** Every runtime module reachable from `entryPoint`, keyed by repo-relative path. */ -const collectRuntimeGraph = (entryPoint: string): Map => { +/** + * Every module reachable from `entryPoint`, keyed by repo-relative path, valued by the packages it + * imports. With `includeTypeOnly`, `import type` edges count too — for both the walk and the + * recorded packages. + */ +const collectGraph = (entryPoint: string, includeTypeOnly = false): Map => { const graph = new Map(); const queue = [path.resolve(REPO_ROOT, entryPoint)]; @@ -83,7 +90,7 @@ const collectRuntimeGraph = (entryPoint: string): Map => { graph.set(relative, packages); for (const { specifier, typeOnly } of parseImports(fs.readFileSync(file, 'utf8'))) { - if (typeOnly) { + if (typeOnly && !includeTypeOnly) { continue; } @@ -107,7 +114,7 @@ const collectRuntimeGraph = (entryPoint: string): Map => { describe('SDK-free modules', () => { it.each(SDK_FREE_ENTRY_POINTS)('%s does not reach casper-js-sdk at runtime', entryPoint => { - const graph = collectRuntimeGraph(entryPoint); + const graph = collectGraph(entryPoint); const offenders = [...graph] .filter(([, packages]) => packages.includes('casper-js-sdk')) @@ -117,12 +124,39 @@ describe('SDK-free modules', () => { }); it('resolves the whole graph (guards against the walker silently finding nothing)', () => { - expect(collectRuntimeGraph('src/utils/casperSdk/blockExplorer.ts').size).toBeGreaterThan(1); + expect(collectGraph('src/utils/casperSdk/blockExplorer.ts').size).toBeGreaterThan(1); }); it('still detects the SDK where it is legitimately used', () => { - const graph = collectRuntimeGraph('src/utils/casperSdk/cep-nft-transfer.ts'); + const graph = collectGraph('src/utils/casperSdk/cep-nft-transfer.ts'); expect([...graph.values()].flat()).toContain('casper-js-sdk'); }); }); + +/** Installed only by clients that use the Ledger integration; see `ICasperLedgerServiceOptions`. */ +const OPTIONAL_LEDGER_PACKAGES = ['@ledgerhq/hw-transport', '@zondax/ledger-casper']; + +/** Entry points a client reaches without opting into Ledger — the package root included. */ +const LEDGER_FREE_ENTRY_POINTS = ['index.ts', 'src/domain/index.ts', 'src/setup.ts']; + +describe('optional Ledger packages', () => { + it.each(LEDGER_FREE_ENTRY_POINTS)( + '%s does not import them, type imports included', + entryPoint => { + const graph = collectGraph(entryPoint, true); + + const offenders = [...graph] + .filter(([, packages]) => packages.some(pkg => OPTIONAL_LEDGER_PACKAGES.includes(pkg))) + .map(([file]) => file); + + expect(offenders).toEqual([]); + }, + ); + + it('still detects them in the file that checks the vendor types', () => { + const graph = collectGraph('src/data/ledger/vendor-contracts.test.ts', true); + + expect([...graph.values()].flat()).toEqual(expect.arrayContaining(OPTIONAL_LEDGER_PACKAGES)); + }); +}); diff --git a/src/setup.integration.test.ts b/src/setup.integration.test.ts index 062d85d..33f7a35 100644 --- a/src/setup.integration.test.ts +++ b/src/setup.integration.test.ts @@ -1,19 +1,32 @@ import { setupRepositories } from './setup'; describe('setupRepositories (integration)', () => { - it('wires all 10 repositories', () => { + /** Every repository the factory returns, so dropping one from the object fails here. */ + const REPOSITORIES = [ + 'accountInfoRepository', + 'appEventsRepository', + 'casperTransactionsRepository', + 'contractPackageRepository', + 'deploysRepository', + 'dexContractRepository', + 'eip712Repository', + 'nftsRepository', + 'onRampRepository', + 'swapRepository', + 'tokensRepository', + 'transactionStatusRepository', + 'txSignatureRequestRepository', + 'validatorsRepository', + ] as const; + + it('wires every repository it claims to', () => { const repos = setupRepositories(); - expect(repos.accountInfoRepository).toBeDefined(); - expect(repos.tokensRepository).toBeDefined(); - expect(repos.onRampRepository).toBeDefined(); - expect(repos.nftsRepository).toBeDefined(); - expect(repos.validatorsRepository).toBeDefined(); - expect(repos.deploysRepository).toBeDefined(); - expect(repos.appEventsRepository).toBeDefined(); - expect(repos.txSignatureRequestRepository).toBeDefined(); - expect(repos.contractPackageRepository).toBeDefined(); - expect(repos.eip712Repository).toBeDefined(); + for (const name of REPOSITORIES) { + expect(repos[name]).toBeDefined(); + } + + expect(Object.keys(repos).sort()).toEqual([...REPOSITORIES].sort()); }); it('honors debug flag (logger is wired)', () => { diff --git a/src/setup.test.ts b/src/setup.test.ts new file mode 100644 index 0000000..a8b8e6a --- /dev/null +++ b/src/setup.test.ts @@ -0,0 +1,115 @@ +import { createPrivateKeySigner } from '../index'; +import { setupRepositories } from './setup'; +import { CasperTransactionsRepository, TransactionStatusRepository } from './data/repositories'; + +const mockSetReferrer = jest.fn(); +const mockSetCustomHeaders = jest.fn(); +const mockHttpHandlerCtor = jest.fn(); +const mockGetStatus = jest.fn(); + +jest.mock('casper-js-sdk', () => ({ + ...jest.requireActual('casper-js-sdk'), + HttpHandler: class { + constructor(...args: unknown[]) { + mockHttpHandlerCtor(...args); + } + setReferrer = mockSetReferrer; + setCustomHeaders = mockSetCustomHeaders; + }, + RpcClient: class { + getStatus = mockGetStatus; + }, +})); + +describe('setupRepositories', () => { + it('returns a casperTransactionsRepository', () => { + const repos = setupRepositories(); + expect(repos.casperTransactionsRepository).toBeInstanceOf(CasperTransactionsRepository); + }); + + it('returns the transactionStatusRepository the flow layer settles through', () => { + const repos = setupRepositories(); + expect(repos.transactionStatusRepository).toBeInstanceOf(TransactionStatusRepository); + }); + + it('gives the swap and dex repositories the same wrapped-CSPR contract package hash', () => { + const wrappedCsprContractPackageHash = { + mainnet: 'aa', + testnet: 'bb', + devnet: 'cc', + integration: 'dd', + }; + const repos = setupRepositories({ wrappedCsprContractPackageHash }); + + // Bracket notation is load-bearing here: dot notation hits `private` at compile time. + // eslint-disable-next-line dot-notation + expect(repos.dexContractRepository['_dexConfig'].wrappedCsprContractPackageHash).toBe( + wrappedCsprContractPackageHash, + ); + // eslint-disable-next-line dot-notation + expect(repos.swapRepository['_wrappedCsprContractPackageHash']).toBe( + wrappedCsprContractPackageHash, + ); + }); + + it('keeps the wallet-API credential off the trade API provider', () => { + const repos = setupRepositories({ httpAuthorizationHeader: 'secret-token' }); + + const defaultHeaders = (provider: unknown) => + (provider as { instance: { headers?: Record } }).instance.headers; + + // eslint-disable-next-line dot-notation + const walletProvider = repos.tokensRepository['_httpProvider']; + // eslint-disable-next-line dot-notation + const tradeProvider = repos.swapRepository['_httpProvider']; + + expect(tradeProvider).not.toBe(walletProvider); + expect(defaultHeaders(tradeProvider)?.Authorization).toBeUndefined(); + expect(defaultHeaders(walletProvider)?.Authorization).toBe('secret-token'); + }); + + it('threads grpcUrl, auth header and rpcOptions', () => { + const grpcUrl = { + mainnet: 'https://x/rpc', + testnet: 'https://x/rpc', + devnet: 'https://x/rpc', + integration: 'https://x/rpc', + }; + const repos = setupRepositories({ + grpcUrl, + httpAuthorizationHeader: 't', + rpcOptions: { handlerType: 'axios', referrerMode: 'referer-header' }, + }); + // eslint-disable-next-line dot-notation + expect(repos.casperTransactionsRepository['_grpcUrl']).toBe(grpcUrl); + // eslint-disable-next-line dot-notation + expect(repos.casperTransactionsRepository['_rpcOptions']).toEqual({ + handlerType: 'axios', + referrerMode: 'referer-header', + authorizationHeader: 't', + }); + // eslint-disable-next-line dot-notation + expect(repos.dexContractRepository['_rpcOptions']).toEqual({ + handlerType: 'axios', + referrerMode: 'referer-header', + }); + }); + + it('zero-config stays browser-safe: the RPC client it builds uses fetch + setReferrer', async () => { + jest.clearAllMocks(); + mockGetStatus.mockResolvedValue({ apiVersion: '2.0.0' }); + + const repos = setupRepositories(); + await repos.casperTransactionsRepository.getNetworkApiVersion('mainnet'); + + expect(mockHttpHandlerCtor).toHaveBeenCalledWith(expect.any(String), 'fetch'); + expect(mockSetReferrer).toHaveBeenCalledWith('https://casperwallet.io'); + expect(mockSetCustomHeaders).not.toHaveBeenCalled(); + }); +}); + +describe('package root exports', () => { + it('resolves the signer factory through the root barrel', () => { + expect(typeof createPrivateKeySigner).toBe('function'); + }); +}); diff --git a/src/setup.ts b/src/setup.ts index d29abf8..5fc409e 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,11 +1,17 @@ -import { CasperWalletApiByEnvUrl, GrpcUrl } from './domain'; +import { CasperWalletApiByEnvUrl, GrpcUrl, WrappedCsprContractPackageHash } from './domain'; import { setupDataRepositories } from './setupData'; import { setupSigningRepositories } from './setupSigning'; import type { ISetupDataRepositoriesParams } from './setupData'; -import type { CasperNetwork } from './domain'; +import type { CasperNetwork, ICasperRpcOptions, IDexConfig } from './domain'; export interface ISetupRepositoriesParams extends ISetupDataRepositoriesParams { grpcUrl?: Record; + dexConfig?: IDexConfig; + /** + * Node-RPC client behavior for casperTransactionsRepository and dexContractRepository. + * Browser-safe defaults; mobile passes axios + referer-header. + */ + rpcOptions?: Omit; } /** @@ -14,13 +20,23 @@ export interface ISetupRepositoriesParams extends ISetupDataRepositoriesParams { * Importing this module links `casper-js-sdk` (~900 KB, one prebuilt UMD bundle that cannot be * tree-shaken), because the signing repositories do. A client that only renders balances and * account lists should call {@link setupDataRepositories} from `src/setupData` instead and pay - * nothing for the SDK — see WALLET-1421. + * nothing for the SDK. */ export const setupRepositories = ({ grpcUrl = GrpcUrl, + dexConfig, + rpcOptions, ...dataParams }: ISetupRepositoriesParams = {}) => { - const { httpDataProvider, log, ...dataRepositories } = setupDataRepositories(dataParams); + // One hash for both halves: `SwapRepository` keys its synthetic native-CSPR token off it and + // `DexContractRepository` validates routes against it. A divergence rejects every native swap. + const wrappedCsprContractPackageHash = + dataParams.wrappedCsprContractPackageHash ?? WrappedCsprContractPackageHash; + + const { httpDataProvider, log, ...dataRepositories } = setupDataRepositories({ + ...dataParams, + wrappedCsprContractPackageHash, + }); const signingRepositories = setupSigningRepositories({ httpDataProvider, @@ -29,7 +45,10 @@ export const setupRepositories = ({ contractPackageRepository: dataRepositories.contractPackageRepository, casperWalletApiByEnvUrl: dataParams.casperWalletApiByEnvUrl ?? CasperWalletApiByEnvUrl, grpcUrl, + wrappedCsprContractPackageHash, httpAuthorizationHeader: dataParams.httpAuthorizationHeader, + dexConfig, + rpcOptions, log, }); diff --git a/src/setupData.ts b/src/setupData.ts index 70aa903..8a12bf1 100644 --- a/src/setupData.ts +++ b/src/setupData.ts @@ -5,6 +5,7 @@ import { ContractPackageRepository } from './data/repositories/contractPackage'; import { DeploysRepository } from './data/repositories/deploys'; import { NftsRepository } from './data/repositories/nfts'; import { OnRampRepository } from './data/repositories/onRamp'; +import { SwapRepository } from './data/repositories/swap'; import { TokensRepository } from './data/repositories/tokens'; import { ValidatorsRepository } from './data/repositories/validators'; import { Logger } from './utils/logger'; @@ -12,6 +13,7 @@ import { CasperWalletApiByNetworkUrl, CasperWalletApiByEnvUrl, } from './domain/constants/casperNetwork'; +import { TradeApiUrl, WrappedCsprContractPackageHash } from './domain/constants'; import type { CasperNetwork } from './domain/common/common'; import type { ILogger } from './domain/common/logger'; import type { IEnv } from './domain/env'; @@ -20,11 +22,10 @@ import type { IEnv } from './domain/env'; * The repositories a wallet client needs to render its home screen — every one of them * SDK-free. * - * Split out of {@link setupRepositories} because that factory constructs the signing - * repositories too, and those link `casper-js-sdk`: a single prebuilt UMD bundle with no ESM - * build and no `sideEffects` flag, so one value import costs ~900 KB that no bundler can shake - * back out. Since the factory constructs everything in one call, no amount of tree shaking on - * the client side could separate them — the split has to happen here (WALLET-1421). + * Kept apart from {@link setupRepositories}, which also constructs the signing repositories: + * those link `casper-js-sdk`, a prebuilt UMD bundle with no ESM build and no `sideEffects` flag, + * so one value import costs ~900 KB no bundler can shake back out. A factory that built both + * halves in one call would give a client no way to take only this one. * * Import this module by path (`casper-wallet-core/src/setupData`), not through the package * root: the root barrel re-exports `./src/setup`, which links the SDK. @@ -39,6 +40,8 @@ export interface ISetupDataRepositoriesParams { /** Environment-based url for Casper Wallet Api. Some API network agnostic and do not belong to any {@link CasperWalletApiByNetworkUrl}. Default env is PRODUCTION (in all places where it is used) */ casperWalletApiByEnvUrl?: Record; httpAuthorizationHeader?: string; + tradeApiByNetworkUrl?: Record; + wrappedCsprContractPackageHash?: Record; } export const setupDataRepositories = ({ @@ -47,6 +50,8 @@ export const setupDataRepositories = ({ casperWalletApiByNetworkUrl = CasperWalletApiByNetworkUrl, casperWalletApiByEnvUrl = CasperWalletApiByEnvUrl, httpAuthorizationHeader, + tradeApiByNetworkUrl = TradeApiUrl, + wrappedCsprContractPackageHash = WrappedCsprContractPackageHash, }: ISetupDataRepositoriesParams = {}) => { const log = logger ?? new Logger(); const httpDataProvider = new HttpDataProvider(debug ? log : null); @@ -76,6 +81,13 @@ export const setupDataRepositories = ({ httpDataProvider, casperWalletApiByNetworkUrl, ); + // Its own provider, without the wallet-API credential: the trade API is a different host, and + // `setAuthHeader` writes `Authorization` on the whole apisauce instance rather than per request. + const swapRepository = new SwapRepository( + new HttpDataProvider(debug ? log : null), + tradeApiByNetworkUrl, + wrappedCsprContractPackageHash, + ); return { accountInfoRepository, @@ -86,6 +98,7 @@ export const setupDataRepositories = ({ deploysRepository, appEventsRepository, contractPackageRepository, + swapRepository, /** Shared with {@link setupSigningRepositories} so both halves talk through one provider. */ httpDataProvider, /** Shared with {@link setupSigningRepositories}; the resolved logger, never `undefined`. */ diff --git a/src/setupSigning.ts b/src/setupSigning.ts index 8631aca..af0a501 100644 --- a/src/setupSigning.ts +++ b/src/setupSigning.ts @@ -1,19 +1,25 @@ -import { EIP712Repository, TxSignatureRequestRepository } from './data/repositories'; -import { GrpcUrl } from './domain'; +import { + CasperTransactionsRepository, + DexContractRepository, + EIP712Repository, + TransactionStatusRepository, + TxSignatureRequestRepository, +} from './data/repositories'; +import { GrpcUrl, TradeContractPackageHash, WrappedCsprContractPackageHash } from './domain'; import type { IDataRepositories } from './setupData'; -import type { CasperNetwork, ILogger } from './domain'; +import type { CasperNetwork, ICasperRpcOptions, ILogger } from './domain'; +import type { IDexConfig } from './domain'; import type { IEnv } from './domain/env'; /** * The repositories that build, parse and sign transactions. * - * These link `casper-js-sdk`, so importing this module costs the whole ~900 KB UMD bundle. - * That is unavoidable — they exist to talk to the chain — which is exactly why they are here - * and not in {@link setupDataRepositories}: a surface that only renders balances and account - * lists must be able to leave this module unimported (WALLET-1421). + * These link `casper-js-sdk`, so importing this module costs the whole ~900 KB UMD bundle. They + * are kept out of {@link setupDataRepositories} so a surface that only renders balances and + * account lists can leave this module unimported. * * Takes the data repositories it depends on rather than constructing its own, so both halves - * share one `HttpDataProvider` and one logger, as they did when a single factory built all ten. + * share one `HttpDataProvider` and one logger. */ export interface ISetupSigningRepositoriesParams extends Pick< IDataRepositories, @@ -21,7 +27,23 @@ export interface ISetupSigningRepositoriesParams extends Pick< > { casperWalletApiByEnvUrl: Record; grpcUrl?: Record; + /** + * The one wrapped-CSPR contract package hash for this setup. `setupRepositories` passes the same + * value to `setupDataRepositories`; the two halves must not be able to disagree. + */ + wrappedCsprContractPackageHash?: Record; httpAuthorizationHeader?: string; + /** + * The trade contract-package hash, gas price and the proxy WASM loader. Optional as a whole — + * omit it and `dexContractRepository` still builds approvals but no swap, wrap or unwrap. + * Supply it and `getProxyWasm` is mandatory; the hash and gas price default. + */ + dexConfig?: IDexConfig; + /** + * Node-RPC client behavior for casperTransactionsRepository and dexContractRepository. + * Browser-safe defaults; mobile passes axios + referer-header. + */ + rpcOptions?: Omit; log: ILogger; } @@ -32,7 +54,10 @@ export const setupSigningRepositories = ({ contractPackageRepository, casperWalletApiByEnvUrl, grpcUrl = GrpcUrl, + wrappedCsprContractPackageHash = WrappedCsprContractPackageHash, httpAuthorizationHeader, + dexConfig, + rpcOptions, log, }: ISetupSigningRepositoriesParams) => { const txSignatureRequestRepository = new TxSignatureRequestRepository( @@ -49,6 +74,37 @@ export const setupSigningRepositories = ({ contractPackageRepository, log, ); + const dexContractRepository = new DexContractRepository( + grpcUrl, + { + tradeContractPackageHash: dexConfig?.tradeContractPackageHash ?? TradeContractPackageHash, + wrappedCsprContractPackageHash, + gasPriceTolerance: dexConfig?.gasPriceTolerance ?? 1, + expectedProxyWasmSha256: dexConfig?.expectedProxyWasmSha256, + getProxyWasm: dexConfig?.getProxyWasm, + }, + httpAuthorizationHeader, + rpcOptions, + log, + ); + const casperTransactionsRepository = new CasperTransactionsRepository( + grpcUrl, + { + ...rpcOptions, + ...(httpAuthorizationHeader ? { authorizationHeader: httpAuthorizationHeader } : {}), + }, + log, + ); + const transactionStatusRepository = new TransactionStatusRepository(grpcUrl, { + ...rpcOptions, + authorizationHeader: httpAuthorizationHeader, + }); - return { txSignatureRequestRepository, eip712Repository }; + return { + txSignatureRequestRepository, + eip712Repository, + dexContractRepository, + casperTransactionsRepository, + transactionStatusRepository, + }; }; diff --git a/src/utils/amounts.property.test.ts b/src/utils/amounts.property.test.ts new file mode 100644 index 0000000..b0b85c8 --- /dev/null +++ b/src/utils/amounts.property.test.ts @@ -0,0 +1,39 @@ +import Big from 'big.js'; +import fc from 'fast-check'; + +import { calculateMaxAmountWithSlippage, calculateMinAmountWithSlippage } from './amounts'; + +// Parity tests: assert the decimal.js implementations match the big.js reference results +// bit-for-bit, so this math cannot silently change on-chain amounts. big.js is a devDependency +// used only here — shipped code never imports it. + +const motes = () => fc.bigInt({ min: 0n, max: 10n ** 30n }).map(String); +const slippageArb = () => fc.integer({ min: 1, max: 5000 }).map(n => n / 100); + +const referenceMin = (amount: string, slippage: number) => + new Big(amount).times(new Big(1).minus(new Big(slippage).div(100))).toFixed(0, Big.roundDown); + +const referenceMax = (amount: string, slippage: number) => + new Big(amount).times(new Big(1).plus(new Big(slippage).div(100))).toFixed(0, Big.roundUp); + +describe('amounts (big.js parity)', () => { + it('min slippage matches the big.js original', () => { + fc.assert( + fc.property(motes(), slippageArb(), (amount, slippage) => { + expect(calculateMinAmountWithSlippage(amount, slippage)).toBe( + referenceMin(amount, slippage), + ); + }), + ); + }); + + it('max slippage matches the big.js original', () => { + fc.assert( + fc.property(motes(), slippageArb(), (amount, slippage) => { + expect(calculateMaxAmountWithSlippage(amount, slippage)).toBe( + referenceMax(amount, slippage), + ); + }), + ); + }); +}); diff --git a/src/utils/amounts.test.ts b/src/utils/amounts.test.ts new file mode 100644 index 0000000..c2afb88 --- /dev/null +++ b/src/utils/amounts.test.ts @@ -0,0 +1,146 @@ +import { + calculateApprovalAmount, + calculateMaxAmountWithSlippage, + calculateMinAmountWithSlippage, + doesAmountExceedBalance, + exceedsMaxDecimals, + hasEnoughCSPRBalance, + isAmountInputValid, + isPositiveAmount, +} from './amounts'; + +describe('amounts', () => { + describe('calculateMinAmountWithSlippage', () => { + it('floors the min amount', () => { + expect(calculateMinAmountWithSlippage('1000000000', 3)).toBe('970000000'); + }); + + it('floors on fractional slippage', () => { + expect(calculateMinAmountWithSlippage('1001', 0.3)).toBe('997'); + }); + + it('keeps every digit of a balance wider than the default Decimal precision', () => { + expect(calculateMinAmountWithSlippage('999999999999999999999', 0.5)).toBe( + '994999999999999999999', + ); + }); + + // 100 would yield '0' — a bound that accepts any output at all. + it.each([100, 150, -1, NaN, Infinity])('throws on slippage %p', slippage => { + expect(() => calculateMinAmountWithSlippage('1000', slippage)).toThrow('outside [0, 100)'); + }); + }); + + describe('calculateMaxAmountWithSlippage', () => { + it('ceils on fractional slippage', () => { + expect(calculateMaxAmountWithSlippage('1001', 0.3)).toBe('1005'); + }); + + it('ceils the max amount', () => { + expect(calculateMaxAmountWithSlippage('1000000000', 3)).toBe('1030000000'); + }); + + it.each([-1, NaN, Infinity])('throws on slippage %p', slippage => { + expect(() => calculateMaxAmountWithSlippage('1000', slippage)).toThrow( + 'finite, non-negative', + ); + }); + }); + + describe('isPositiveAmount', () => { + it('rejects zero', () => { + expect(isPositiveAmount('0')).toBe(false); + }); + + it('accepts a positive amount', () => { + expect(isPositiveAmount('0.1')).toBe(true); + }); + }); + + describe('isAmountInputValid', () => { + it('allows empty string', () => { + expect(isAmountInputValid('')).toBe(true); + }); + + it('rejects too many decimals', () => { + expect(isAmountInputValid('1.234', 2)).toBe(false); + }); + + it('rejects non-numeric input', () => { + expect(isAmountInputValid('12a')).toBe(false); + }); + + it('accepts a leading and a trailing dot', () => { + expect(isAmountInputValid('.5')).toBe(true); + expect(isAmountInputValid('1.')).toBe(true); + }); + + it('rejects a second dot', () => { + expect(isAmountInputValid('1.2.3')).toBe(false); + }); + + it('rejects a long invalid input in linear time', () => { + const started = Date.now(); + + expect(isAmountInputValid(`${'0'.repeat(50_000)}x`)).toBe(false); + expect(Date.now() - started).toBeLessThan(250); + }); + }); + + describe('doesAmountExceedBalance', () => { + it('detects the amount exceeding the balance', () => { + expect(doesAmountExceedBalance('5', '4')).toBe(true); + }); + + it('returns false for empty amount', () => { + expect(doesAmountExceedBalance('', '4')).toBe(false); + }); + + it('returns false for invalid amount', () => { + expect(doesAmountExceedBalance('abc', '4')).toBe(false); + }); + }); + + describe('exceedsMaxDecimals', () => { + it('detects too many decimals', () => { + expect(exceedsMaxDecimals('1.234', 2)).toBe(true); + }); + + it('accepts decimals within the limit', () => { + expect(exceedsMaxDecimals('1.23', 2)).toBe(false); + }); + }); + + describe('calculateApprovalAmount', () => { + it('adds a 20% buffer over the required amount', () => { + expect(calculateApprovalAmount('1000000000')).toBe('1200000000'); + }); + + it('always clears the amount it was derived from', () => { + const required = calculateMaxAmountWithSlippage('1000000000', 50); + + expect(Number(calculateApprovalAmount(required))).toBeGreaterThan(Number(required)); + }); + + it('returns 0 for an empty or zero amount', () => { + expect(calculateApprovalAmount('')).toBe('0'); + expect(calculateApprovalAmount('0')).toBe('0'); + }); + + it('truncates like the integer math it replaces', () => { + // (BigInt(x) * 120n) / 100n for the same inputs + expect(calculateApprovalAmount('7')).toBe('8'); + expect(calculateApprovalAmount('1')).toBe('1'); + }); + }); + + describe('hasEnoughCSPRBalance', () => { + it('reports sufficient balance', () => { + expect(hasEnoughCSPRBalance('100', '60', '40')).toBe(true); + }); + + it('reports insufficient balance', () => { + expect(hasEnoughCSPRBalance('99', '60', '40')).toBe(false); + }); + }); +}); diff --git a/src/utils/amounts.ts b/src/utils/amounts.ts new file mode 100644 index 0000000..659a240 --- /dev/null +++ b/src/utils/amounts.ts @@ -0,0 +1,134 @@ +import Decimal from 'decimal.js'; + +import { AmountDecimal as D } from './decimal'; + +/** + * Minimum acceptable amount with slippage protection: + * `expectedAmount * (1 - slippagePercent / 100)`, rounded DOWN to protect the user. + * + * @throws if `slippagePercent` is not a finite number in `[0, 100)`. A slippage of `100` + * would yield `0` — a bound that permits any output at all — so it fails closed rather than + * returning an unprotected amount. + */ +export const calculateMinAmountWithSlippage = ( + expectedAmount: string, + slippagePercent: number, +): string => { + if (!Number.isFinite(slippagePercent) || slippagePercent < 0 || slippagePercent >= 100) { + throw new Error( + `Failed to calculate min amount with slippage: slippage="${slippagePercent}" is outside [0, 100)`, + ); + } + + try { + const factor = new D(1).minus(new D(slippagePercent).div(100)); + + return new D(expectedAmount).times(factor).toFixed(0, Decimal.ROUND_DOWN); + } catch (error) { + throw new Error( + `Failed to calculate min amount with slippage: expectedAmount="${expectedAmount}", slippage="${slippagePercent}". Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** + * Maximum acceptable amount with slippage protection: + * `expectedAmount * (1 + slippagePercent / 100)`, rounded UP to protect the protocol. + * + * @throws if `slippagePercent` is not a finite, non-negative number. A negative value would + * invert the bound into a minimum. + */ +export const calculateMaxAmountWithSlippage = ( + expectedAmount: string, + slippagePercent: number, +): string => { + if (!Number.isFinite(slippagePercent) || slippagePercent < 0) { + throw new Error( + `Failed to calculate max amount with slippage: slippage="${slippagePercent}" must be a finite, non-negative number`, + ); + } + + try { + const factor = new D(1).plus(new D(slippagePercent).div(100)); + + return new D(expectedAmount).times(factor).toFixed(0, Decimal.ROUND_UP); + } catch (error) { + throw new Error( + `Failed to calculate max amount with slippage: expectedAmount="${expectedAmount}", slippage="${slippagePercent}". Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** `true` if `amount` is a non-empty, valid, strictly positive number string (`'0'` is invalid). */ +export const isPositiveAmount = (amount: string): boolean => { + if (!amount || amount === '0' || amount === '') return false; + const numValue = parseFloat(amount); + + return !isNaN(numValue) && numValue > 0; +}; + +/** `true` if `amount` has more fraction digits than `decimals`. */ +export const exceedsMaxDecimals = (amount: string, decimals: number): boolean => { + const parts = amount.split('.'); + if (parts.length === 1) return false; + + return parts[1].length > decimals; +}; + +/** + * `true` if `amount` is acceptable as in-progress user input: empty string is allowed, must + * be digits with an optional single dot, and (if `decimals` is given) capped to that many + * fraction digits. + */ +export const isAmountInputValid = (amount: string, decimals?: number): boolean => { + if (amount === '') return true; + + if (!/^\d*(\.\d*)?$/.test(amount)) return false; + + if (typeof decimals === 'number' && decimals >= 0) { + return !exceedsMaxDecimals(amount, decimals); + } + + return true; +}; + +/** `true` if `amount` is strictly greater than `balance`; invalid input returns `false`. */ +export const doesAmountExceedBalance = (amount: string, balance: string | number): boolean => { + if (!amount) return false; + + try { + return new D(amount).gt(new D(balance)); + } catch { + return false; + } +}; + +const APPROVAL_BUFFER_PERCENT = 20; + +/** + * Raw amount to approve for a spender: `requiredAmount` plus a + * {@link APPROVAL_BUFFER_PERCENT}% buffer, truncated to whole base units. Pass the same + * amount the approval check is made against, so the grant always clears the check. + * Returns `'0'` for an empty or zero input. + */ +export const calculateApprovalAmount = (requiredAmount: string): string => { + if (!requiredAmount || requiredAmount === '0') { + return '0'; + } + + return new D(requiredAmount) + .times(100 + APPROVAL_BUFFER_PERCENT) + .div(100) + .toFixed(0, Decimal.ROUND_DOWN); +}; + +/** `true` if `liquidCsprBalance` covers `csprAmountNeeded` plus `transactionFeeInMotes` (all in motes). */ +export const hasEnoughCSPRBalance = ( + liquidCsprBalance: string, + csprAmountNeeded: string, + transactionFeeInMotes: string, +): boolean => { + const totalNeeded = new D(csprAmountNeeded).plus(new D(transactionFeeInMotes)); + + return new D(liquidCsprBalance).gte(totalNeeded); +}; diff --git a/src/utils/casperSdk/cep-nft-transfer.test.ts b/src/utils/casperSdk/cep-nft-transfer.test.ts index 02d304e..e5083ea 100644 --- a/src/utils/casperSdk/cep-nft-transfer.test.ts +++ b/src/utils/casperSdk/cep-nft-transfer.test.ts @@ -6,6 +6,7 @@ import { makeNftTransferDeploy, makeNftTransferTransaction, NFTTokenStandard, + NftStandardToSdkStandardMap, } from './cep-nft-transfer'; const SENDER = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; @@ -117,3 +118,13 @@ describe('cep-nft-transfer', () => { }); }); }); + +describe('NftStandardToSdkStandardMap', () => { + it('covers all standards', () => { + expect(NftStandardToSdkStandardMap).toEqual({ + CEP47: NFTTokenStandard.CEP47, + CEP78: NFTTokenStandard.CEP78, + CEP95: NFTTokenStandard.CEP95, + }); + }); +}); diff --git a/src/utils/casperSdk/cep-nft-transfer.ts b/src/utils/casperSdk/cep-nft-transfer.ts index 5245f6b..1baf7a2 100644 --- a/src/utils/casperSdk/cep-nft-transfer.ts +++ b/src/utils/casperSdk/cep-nft-transfer.ts @@ -18,6 +18,7 @@ import { Transaction, CLTypeUInt8, } from 'casper-js-sdk'; +import { NftStandard } from '../../domain/nfts'; export enum NFTTokenStandard { CEP47 = 'CEP47', @@ -25,6 +26,12 @@ export enum NFTTokenStandard { CEP95 = 'CEP95', } +export const NftStandardToSdkStandardMap: Record = { + CEP47: NFTTokenStandard.CEP47, + CEP78: NFTTokenStandard.CEP78, + CEP95: NFTTokenStandard.CEP95, +}; + export interface IMakeNftTransferDeployParams { nftStandard: NFTTokenStandard; contractPackageHash: string; diff --git a/src/utils/casperSdk/dex-contract.test.ts b/src/utils/casperSdk/dex-contract.test.ts new file mode 100644 index 0000000..c915486 --- /dev/null +++ b/src/utils/casperSdk/dex-contract.test.ts @@ -0,0 +1,45 @@ +import { CLValue, Key, PublicKey } from 'casper-js-sdk'; + +import { keysToHex } from './dex-contract'; + +import { TradeContractPackageHash } from '../../domain'; + +const PUBLIC_KEY = '0106956df3aba7115e28271d053205ec7f33cab259f8e2da2f38150f0ece65a2a8'; + +const accountKey = () => + CLValue.newCLKey(Key.newKey(PublicKey.fromHex(PUBLIC_KEY).accountHash().toPrefixedString())); +const operatorKey = () => CLValue.newCLKey(Key.newKey(TradeContractPackageHash.mainnet)); + +/** + * Fixed vectors, not a re-run of the implementation. The digest was cross-checked against + * Python's `hashlib.blake2b(a + b, digest_size=32)` — a separate blake2b implementation — so a + * change to `dkLen` or to the concatenation order moves the left side only and fails. + * + * The inputs are this repo's own serialization, so these pin the derivation against drift; they + * do not independently confirm that the deployed CEP-18 contracts key `allowances` this way. + */ +const ACCOUNT_KEY_BYTES = '000379ee3245b15cb03dafa417451b07735c032953be6301a0bf253b68309eaacc'; +const OPERATOR_KEY_BYTES = '011dbac65585475fec53e5b1f9110923c8d232921702097e83105b36751d682186'; +const ALLOWANCES_DICT_KEY = 'd3cf5c22d374ac6ec3e20c825ad6f38b47f15ba4db675ab3ab598d0fa6c03782'; + +const toHex = (bytes: Uint8Array) => + Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + +describe('keysToHex', () => { + it('serializes an account key and a contract-package key to the expected bytes', () => { + expect(toHex(accountKey().bytes())).toBe(ACCOUNT_KEY_BYTES); + expect(toHex(operatorKey().bytes())).toBe(OPERATOR_KEY_BYTES); + }); + + it('derives the allowances dictionary key as blake2b-256 over owner ++ spender', () => { + expect(keysToHex(accountKey(), operatorKey())).toBe(ALLOWANCES_DICT_KEY); + }); + + it('is order-sensitive: owner ++ spender is not spender ++ owner', () => { + expect(keysToHex(operatorKey(), accountKey())).not.toBe(ALLOWANCES_DICT_KEY); + }); + + it('returns a 32-byte digest', () => { + expect(keysToHex(accountKey(), operatorKey())).toHaveLength(64); + }); +}); diff --git a/src/utils/casperSdk/dex-contract.ts b/src/utils/casperSdk/dex-contract.ts new file mode 100644 index 0000000..1b946e2 --- /dev/null +++ b/src/utils/casperSdk/dex-contract.ts @@ -0,0 +1,111 @@ +import { + CLValue, + Conversions, + ParamDictionaryIdentifier, + ParamDictionaryIdentifierContractNamedKey, + type RpcClient, +} from 'casper-js-sdk'; +import { blake2b } from '@noble/hashes/blake2'; +import { concatBytes } from '@noble/hashes/utils'; + +/** + * SDK-backed contract-package/dictionary helpers. + * + * This module value-imports `casper-js-sdk`, so it is deliberately excluded from the + * `casperSdk` barrel (see that barrel's module comment) — only `src/data/repositories/dex` + * should import it directly. + */ + +export interface IContractHashResult { + contractHash: string; +} + +/** Resolves the active (highest-version) contract hash for a contract package. */ +export const getContractHash = async ( + contractPackageHash: string, + client: RpcClient, +): Promise => { + if (!contractPackageHash) { + throw new Error('Contract package hash not found'); + } + + const { + storedValue: { contractPackage }, + } = await client.queryLatestGlobalState(`hash-${contractPackageHash}`, []); + + if (!contractPackage) { + throw new Error('Not found contract package'); + } + + const latestVersion = contractPackage.versions.reduce((highest, v) => + highest.contractVersion > v.contractVersion ? highest : v, + ); + + return { + contractHash: latestVersion.contractHash.hash.toHex().replace('contract-', ''), + }; +}; + +/** + * `ErrorCode.QueryFailed` / `ErrorCode.FailedToGetDictionaryURef` — the node answered and the + * item is not in state. Anything else (transport, HTTP, a node error) is a failed read. + */ +const ABSENT_DICTIONARY_RPC_CODES = [-32003, -32010]; + +/** + * The sdk reports an RPC error as `HttpError(code, RpcError)`, so the code sits on `statusCode` + * and on the wrapped `sourceErr` — never on the thrown error itself. + */ +const isAbsentDictionaryError = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null) { + return false; + } + + const { code, statusCode, sourceErr } = error as { + code?: unknown; + statusCode?: unknown; + sourceErr?: { code?: unknown }; + }; + + return [code, statusCode, sourceErr?.code] + .filter(value => value != null) + .map(Number) + .some(value => ABSENT_DICTIONARY_RPC_CODES.includes(value)); +}; + +/** + * Reads a dictionary value by contract-named-key identifier. + * + * `null` means the entry is absent. A failed read — unreachable node, HTTP error, malformed + * response — throws rather than reading as absent. + */ +export const getDictionaryValue = async ( + client: RpcClient, + contractHash: string, + dictionaryName: string, + dictKey: string, +): Promise => { + const identifier = new ParamDictionaryIdentifier( + undefined, + new ParamDictionaryIdentifierContractNamedKey(`hash-${contractHash}`, dictionaryName, dictKey), + ); + + try { + const result = await client.getDictionaryItemByIdentifier(null, identifier); + + return result.storedValue?.clValue ?? null; + } catch (error) { + if (isAbsentDictionaryError(error)) { + return null; + } + + throw error; + } +}; + +/** Blake2b-256 of the concatenated key bytes — the dictionary-key scheme CEP-18 contracts use. */ +export const keysToHex = (keyA: CLValue, keyB: CLValue): string => { + const blaked = blake2b(concatBytes(keyA.bytes(), keyB.bytes()), { dkLen: 32 }); + + return Conversions.encodeBase16(blaked); +}; diff --git a/src/utils/casperSdk/index.ts b/src/utils/casperSdk/index.ts index c3745fe..39649a2 100644 --- a/src/utils/casperSdk/index.ts +++ b/src/utils/casperSdk/index.ts @@ -1,6 +1,8 @@ /** - * Barrel over the Casper-protocol helpers. Only `./cep-nft-transfer` links `casper-js-sdk`; the - * other three modules are deliberately SDK-free. + * Barrel over the Casper-protocol helpers. Only the SDK-free modules — `./accountHash`, + * `./network` and `./blockExplorer` — are re-exported here: this barrel is reached from the + * `utils` barrel that most of `src/data` imports, so re-exporting an SDK-linked module would make + * every DTO a transitive SDK importer for builds that do not tree-shake. * * `casper-js-sdk` ships a single prebuilt UMD bundle (`dist/lib.web.js` — no `module` field, no * `import` condition, no `sideEffects` flag), so one import of it costs the whole ~900 KB blob and @@ -11,15 +13,14 @@ * - `casper-wallet-core/src/utils/casperSdk/network` — `getCasperNetworkByChainName` * - `casper-wallet-core/src/utils/casperSdk/blockExplorer` — `getBlockExplorer*Url`, `getContractNftUrl` * - `casper-wallet-core/src/utils/casperSdk/cep-nft-transfer` — the SDK-backed deploy builders + * - `casper-wallet-core/src/utils/casperSdk/tx-builders` — `build*Transactions` for transfers, + * CEP-18 and the auction manager + * - `casper-wallet-core/src/utils/casperSdk/validation` — `isValidCasperPublicKey` * * The package declares `"sideEffects": false`, so a bundler that tree-shakes will also drop the * unused halves when importing through this barrel or the package root — the deep paths are the - * guarantee for builds that do not. - * - * `./cep-nft-transfer` is deliberately NOT re-exported here: this barrel is reached from the - * `utils` barrel, which most of `src/data` imports, so re-exporting it made every DTO a - * transitive SDK importer for builds that do not shake. It is re-exported from the package root - * instead, so the public API is unchanged (WALLET-1421). + * guarantee for builds that do not. `./dex-contract` and `./rpcClient` are exported nowhere: they + * are internal to `src/data/repositories`. */ export * from './accountHash'; diff --git a/src/utils/casperSdk/rpcClient.ts b/src/utils/casperSdk/rpcClient.ts new file mode 100644 index 0000000..f3eb406 --- /dev/null +++ b/src/utils/casperSdk/rpcClient.ts @@ -0,0 +1,27 @@ +import { HttpHandler, RpcClient } from 'casper-js-sdk'; +import { CSPR_API_PROXY_HEADERS } from '../../domain/constants'; +import type { ICasperRpcOptions } from '../../domain'; + +/** Builds the RPC client every repository that talks to a node uses. */ +export const createCasperRpcClient = (url: string, options: ICasperRpcOptions = {}): RpcClient => { + const { handlerType = 'fetch', referrerMode = 'fetch-referrer', authorizationHeader } = options; + + const handler = new HttpHandler(url, handlerType); + const customHeaders: Record = {}; + + if (referrerMode === 'referer-header') { + customHeaders.Referer = CSPR_API_PROXY_HEADERS.Referer; + } else { + handler.setReferrer(CSPR_API_PROXY_HEADERS.Referer); + } + + if (authorizationHeader) { + customHeaders.Authorization = authorizationHeader; + } + + if (Object.keys(customHeaders).length > 0) { + handler.setCustomHeaders(customHeaders); + } + + return new RpcClient(handler); +}; diff --git a/src/utils/casperSdk/tx-builders.test.ts b/src/utils/casperSdk/tx-builders.test.ts new file mode 100644 index 0000000..3ce549b --- /dev/null +++ b/src/utils/casperSdk/tx-builders.test.ts @@ -0,0 +1,368 @@ +import { + AuctionManagerEntryPoint, + CasperNetworkName, + Deploy, + KeyAlgorithm, + makeAuctionManagerDeploy, + makeCep18TransferDeploy, + makeCsprTransferDeploy, + PrivateKey, +} from 'casper-js-sdk'; +import { CasperNetwork } from '../../domain/common'; +import { CasperSdkNetworkName } from '../../domain/constants'; +import * as casperSdkBarrel from './index'; +import { + AuctionManagerEntryPointMap, + buildAuctionManagerTransactions, + buildCep18TransferTransactions, + buildCsprTransferTransactions, + buildNftTransferTransactions, +} from './tx-builders'; +import { + makeNftTransferDeploy, + makeNftTransferTransaction, + NFTTokenStandard, +} from './cep-nft-transfer'; + +const TS = '2026-01-01T00:00:00.000Z'; +const sender = PrivateKey.generate(KeyAlgorithm.SECP256K1).publicKey.toHex(); +const recipient = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); +const PKG = 'b2ec4f982efa8643c979cb3ab42ad1a18851c2e6f91804cd3e65c079679bdc59'; + +describe('buildCsprTransferTransactions', () => { + const params = { + network: 'testnet' as const, + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + transferAmountMotes: '2500000000', + timestamp: TS, + }; + + it('2.x: native TransactionV1 + legacy fallback deploy', () => { + const { transaction, fallbackDeploy } = buildCsprTransferTransactions(params, '2.0.0'); + expect(transaction.getDeploy()).toBeFalsy(); + expect(fallbackDeploy).toBeTruthy(); + expect(transaction.chainName).toBe('casper-test'); + }); + + it('1.x: deploy-wrapped transaction, byte-equal to a direct SDK deploy build', () => { + const { transaction } = buildCsprTransferTransactions(params, '1.5.8'); + const deploy = transaction.getDeploy(); + expect(deploy).toBeTruthy(); + + const direct = makeCsprTransferDeploy({ + chainName: 'casper-test', + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + transferAmount: '2500000000', + timestamp: TS, + }); + expect(JSON.stringify(Deploy.toJSON(deploy as Deploy))).toBe( + JSON.stringify(Deploy.toJSON(direct)), + ); + }); + + it('memo passes through untouched and is not defaulted', () => { + // The SDK carries `memo` as the transfer's numeric `id` CLValue (`Some`/`None` of U64), so + // the assertion reads that arg rather than matching text. + const withMemo = buildCsprTransferTransactions({ ...params, memo: '777' }, '1.5.8'); + const noMemo = buildCsprTransferTransactions(params, '1.5.8'); + const idArg = (name: [string, { bytes: string }][]) => + name.find(([argName]) => argName === 'id')?.[1].bytes; + + const withMemoArgs = (Deploy.toJSON(withMemo.fallbackDeploy) as any).session.Transfer.args; + const noMemoArgs = (Deploy.toJSON(noMemo.fallbackDeploy) as any).session.Transfer.args; + expect(idArg(withMemoArgs)).toBe('010903000000000000'); + expect(idArg(noMemoArgs)).toBe('00'); + + // deterministic: two no-memo builds are identical (no Date.now injection) + expect(JSON.stringify(Deploy.toJSON(noMemo.fallbackDeploy))).toBe( + JSON.stringify(Deploy.toJSON(buildCsprTransferTransactions(params, '1.5.8').fallbackDeploy)), + ); + }); + + it('gasPrice defaults to 1 on both tx and fallback when omitted', () => { + const { transaction, fallbackDeploy } = buildCsprTransferTransactions(params, '2.0.0'); + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.pricing_mode.PaymentLimited.gas_price_tolerance).toBe(1); + expect((Deploy.toJSON(fallbackDeploy) as any).header.gas_price).toBe(1); + }); + + it.each([ + ['mainnet', 'casper'], + ['testnet', 'casper-test'], + ['devnet', 'dev-net'], + ['integration', 'integration-test'], + ] satisfies [CasperNetwork, string][])( + 'maps network %s to chain name %s via CasperSdkNetworkName', + (network, chainName) => { + const { transaction } = buildCsprTransferTransactions({ ...params, network }, '2.0.0'); + expect(transaction.chainName).toBe(chainName); + }, + ); +}); + +describe('buildCep18TransferTransactions', () => { + const params = { + network: 'mainnet' as const, + contractPackageHash: PKG, + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + transferAmountMotes: '25000000000', + paymentAmountMotes: '3000000000', + timestamp: TS, + }; + + it('2.x: V1 tx targets the package hash; fallback deploy targets the same package with a transfer entry point', () => { + const { transaction, fallbackDeploy } = buildCep18TransferTransactions(params, '2.0.0'); + expect(transaction.getDeploy()).toBeFalsy(); + + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.fields.target.Stored.id.ByPackageHash.addr).toBe(PKG); + expect(txJson.payload.fields.entry_point).toEqual({ Custom: 'transfer' }); + + const fallbackJson = Deploy.toJSON(fallbackDeploy) as any; + expect(fallbackJson.session.StoredVersionedContractByHash.hash).toBe(PKG); + expect(fallbackJson.session.StoredVersionedContractByHash.entry_point).toBe('transfer'); + }); + + it('1.x: deploy-wrapped transaction, byte-equal to a direct SDK deploy build', () => { + const { transaction, fallbackDeploy } = buildCep18TransferTransactions(params, '1.5.8'); + const deploy = transaction.getDeploy(); + expect(deploy).toBeTruthy(); + expect(fallbackDeploy).toBeInstanceOf(Deploy); + + const direct = makeCep18TransferDeploy({ + chainName: 'casper', + contractPackageHash: PKG, + paymentAmount: '3000000000', + recipientPublicKeyHex: recipient, + senderPublicKeyHex: sender, + transferAmount: '25000000000', + timestamp: TS, + }); + expect(JSON.stringify(Deploy.toJSON(deploy as Deploy))).toBe( + JSON.stringify(Deploy.toJSON(direct)), + ); + }); + + it('paymentAmountMotes lands in payment on both the 2.x tx and the fallback deploy', () => { + const { transaction, fallbackDeploy } = buildCep18TransferTransactions(params, '2.0.0'); + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.pricing_mode.PaymentLimited.payment_amount).toBe(3000000000); + + const paymentArgs = (Deploy.toJSON(fallbackDeploy) as any).payment.ModuleBytes.args; + // U512 CLValue: `04` length prefix + little-endian 0xb2d05e00 = 3000000000 + expect(Object.fromEntries(paymentArgs).amount.bytes).toBe('04005ed0b2'); + }); + + it('maps network to chain name via CasperSdkNetworkName', () => { + const { transaction } = buildCep18TransferTransactions( + { ...params, network: 'testnet' }, + '2.0.0', + ); + expect(transaction.chainName).toBe(CasperSdkNetworkName.testnet); + }); +}); + +describe('buildAuctionManagerTransactions', () => { + const params = { + network: 'testnet' as const, + delegatorPublicKeyHex: sender, + validatorPublicKeyHex: recipient, + amountMotes: '500000000000', + paymentAmountMotes: '2500000000', + timestamp: TS, + }; + + it.each([ + ['DELEGATE', 'Delegate', 'delegate'] as const, + ['UNDELEGATE', 'Undelegate', 'undelegate'] as const, + ])( + '%s maps to the SDK entry point on both tx and fallback, without a new-validator arg', + (entryPoint, txEntryPoint, fallbackEntryPoint) => { + const { transaction, fallbackDeploy } = buildAuctionManagerTransactions( + { ...params, entryPoint, newValidatorPublicKeyHex: recipient }, + '2.0.0', + ); + + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.fields.entry_point).toBe(txEntryPoint); + const txArgNames = txJson.payload.fields.args.Named.map(([name]: [string]) => name); + expect(txArgNames).not.toContain('new_validator'); + + const fallbackJson = Deploy.toJSON(fallbackDeploy) as any; + expect(fallbackJson.session.StoredContractByHash.entry_point).toBe(fallbackEntryPoint); + }, + ); + + it('REDELEGATE passes newValidatorPublicKeyHex through to both tx and fallback', () => { + const newValidator = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); + const { transaction, fallbackDeploy } = buildAuctionManagerTransactions( + { ...params, entryPoint: 'REDELEGATE', newValidatorPublicKeyHex: newValidator }, + '2.0.0', + ); + + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.fields.entry_point).toBe('Redelegate'); + const txArgNames = txJson.payload.fields.args.Named.map(([name]: [string]) => name); + expect(txArgNames).toContain('new_validator'); + + const fallbackJson = Deploy.toJSON(fallbackDeploy) as any; + expect(fallbackJson.session.StoredContractByHash.entry_point).toBe('redelegate'); + const fallbackArgNames = fallbackJson.session.StoredContractByHash.args.map( + ([name]: [string]) => name, + ); + expect(fallbackArgNames).toContain('new_validator'); + }); + + it('1.x: deploy-wrapped transaction, byte-equal to a direct SDK deploy build', () => { + const { transaction, fallbackDeploy } = buildAuctionManagerTransactions( + { ...params, entryPoint: 'DELEGATE' }, + '1.5.8', + ); + const deploy = transaction.getDeploy(); + expect(deploy).toBeTruthy(); + expect(fallbackDeploy).toBeInstanceOf(Deploy); + + const direct = makeAuctionManagerDeploy({ + amount: '500000000000', + paymentAmount: '2500000000', + chainName: CasperNetworkName.Testnet, + contractEntryPoint: AuctionManagerEntryPoint.delegate, + delegatorPublicKeyHex: sender, + validatorPublicKeyHex: recipient, + timestamp: TS, + }); + expect(JSON.stringify(Deploy.toJSON(deploy as Deploy))).toBe( + JSON.stringify(Deploy.toJSON(direct)), + ); + }); + + it('gasPrice defaults to 1 when omitted', () => { + const { transaction, fallbackDeploy } = buildAuctionManagerTransactions( + { ...params, entryPoint: 'DELEGATE' }, + '2.0.0', + ); + const txJson = transaction.toJSON() as unknown as Record; + expect(txJson.payload.pricing_mode.PaymentLimited.gas_price_tolerance).toBe(1); + expect((Deploy.toJSON(fallbackDeploy) as any).header.gas_price).toBe(1); + }); +}); + +describe('buildNftTransferTransactions', () => { + const params = { + network: 'mainnet' as const, + contractPackageHash: PKG, + nftStandard: 'CEP78' as const, + senderPublicKeyHex: sender, + recipientPublicKeyHex: recipient, + paymentAmountMotes: '15000000000', + tokenId: '1', + timestamp: TS, + }; + + it('fallback deploy is byte-equal to the legacy "1.5.8" hack (D11)', () => { + const { fallbackDeploy } = buildNftTransferTransactions(params, '2.0.0'); + const legacy = makeNftTransferTransaction({ + chainName: 'casper', + contractPackageHash: PKG, + nftStandard: NFTTokenStandard.CEP78, + paymentAmount: '15000000000', + recipientPublicKeyHex: recipient, + senderPublicKeyHex: sender, + tokenId: '1', + timestamp: TS, + casperNetworkApiVersion: '1.5.8', + gasPrice: 1, + }).getDeploy(); + expect(JSON.stringify(Deploy.toJSON(fallbackDeploy))).toBe( + JSON.stringify(Deploy.toJSON(legacy as Deploy)), + ); + }); + + it('1.x: deploy-wrapped transaction, byte-equal to a direct SDK deploy build', () => { + const { transaction, fallbackDeploy } = buildNftTransferTransactions(params, '1.5.8'); + const deploy = transaction.getDeploy(); + expect(deploy).toBeTruthy(); + expect(fallbackDeploy).toBeInstanceOf(Deploy); + + const direct = makeNftTransferDeploy({ + chainName: 'casper', + contractPackageHash: PKG, + nftStandard: NFTTokenStandard.CEP78, + paymentAmount: '15000000000', + recipientPublicKeyHex: recipient, + senderPublicKeyHex: sender, + tokenId: '1', + timestamp: TS, + gasPrice: 1, + }); + expect(JSON.stringify(Deploy.toJSON(deploy as Deploy))).toBe( + JSON.stringify(Deploy.toJSON(direct)), + ); + }); + + it('CEP95, 2.x: V1 tx entry point transfer_from', () => { + const { transaction } = buildNftTransferTransactions( + { ...params, nftStandard: 'CEP95' }, + '2.0.0', + ); + const json = transaction.toJSON() as unknown as Record; + expect(json.payload.fields.entry_point).toEqual({ Custom: 'transfer_from' }); + }); + + it('tokenId sets is_hash_identifier_mode false and forwards token_id', () => { + const { transaction } = buildNftTransferTransactions(params, '2.0.0'); + const args = (transaction.toJSON() as unknown as Record).payload.fields.args.Named; + const byName = Object.fromEntries(args); + expect(byName.is_hash_identifier_mode.bytes).toBe('00'); + expect(byName.token_id).toBeDefined(); + expect(byName.token_hash).toBeUndefined(); + }); + + it('tokenHash sets is_hash_identifier_mode true and forwards token_hash', () => { + const { transaction } = buildNftTransferTransactions( + { ...params, tokenId: undefined, tokenHash: 'deadbeef' }, + '2.0.0', + ); + const args = (transaction.toJSON() as unknown as Record).payload.fields.args.Named; + const byName = Object.fromEntries(args); + expect(byName.is_hash_identifier_mode.bytes).toBe('01'); + expect(byName.token_hash).toBeDefined(); + expect(byName.token_id).toBeUndefined(); + }); + + it('throws when neither tokenId nor tokenHash is provided', () => { + expect(() => buildNftTransferTransactions({ ...params, tokenId: undefined }, '2.0.0')).toThrow( + /Specify either tokenId or tokenHash/, + ); + }); +}); + +describe('constants moved next to the SDK (D13)', () => { + it('auction entry point map matches the SDK enum', () => { + expect(AuctionManagerEntryPointMap).toEqual({ + DELEGATE: AuctionManagerEntryPoint.delegate, + UNDELEGATE: AuctionManagerEntryPoint.undelegate, + REDELEGATE: AuctionManagerEntryPoint.redelegate, + }); + }); + + it('CasperSdkNetworkName values equal the SDK CasperNetworkName values', () => { + expect(CasperSdkNetworkName).toEqual({ + mainnet: CasperNetworkName.Mainnet, + testnet: CasperNetworkName.Testnet, + devnet: CasperNetworkName.DevNet, + integration: CasperNetworkName.Integration, + }); + }); +}); + +describe('barrel discipline', () => { + it('is not re-exported from the SDK-free casperSdk barrel', () => { + expect( + (casperSdkBarrel as Record).buildCsprTransferTransactions, + ).toBeUndefined(); + }); +}); diff --git a/src/utils/casperSdk/tx-builders.ts b/src/utils/casperSdk/tx-builders.ts new file mode 100644 index 0000000..55cbe41 --- /dev/null +++ b/src/utils/casperSdk/tx-builders.ts @@ -0,0 +1,265 @@ +import { + AuctionManagerEntryPoint, + Deploy, + makeAuctionManagerDeploy, + makeAuctionManagerTransaction, + makeCep18TransferDeploy, + makeCep18TransferTransaction, + makeCsprTransferDeploy, + makeCsprTransferTransaction, + Transaction, +} from 'casper-js-sdk'; +// Type-only: `makeAuctionManager{Deploy,Transaction}` type their `chainName` param as the SDK +// enum, unlike the other three builders (which accept a plain string) — see the cast below. +import type { CasperNetworkName } from 'casper-js-sdk'; +import { AuctionManagerEntryPointType, CasperSdkNetworkName } from '../../domain/constants'; +import { CasperNetwork } from '../../domain/common'; +import { NftStandard } from '../../domain/nfts'; +import { + makeNftTransferDeploy, + makeNftTransferTransaction, + NftStandardToSdkStandardMap, +} from './cep-nft-transfer'; + +/** Lives here, not in `src/domain/constants` — it value-imports the SDK enum. */ +export const AuctionManagerEntryPointMap: Record< + AuctionManagerEntryPointType, + | AuctionManagerEntryPoint.delegate + | AuctionManagerEntryPoint.redelegate + | AuctionManagerEntryPoint.undelegate +> = { + DELEGATE: AuctionManagerEntryPoint.delegate, + UNDELEGATE: AuctionManagerEntryPoint.undelegate, + REDELEGATE: AuctionManagerEntryPoint.redelegate, +}; + +export interface IBuiltCasperTransaction { + transaction: Transaction; + /** Legacy Deploy for signers that cannot sign TransactionV1 (old Ledger apps). */ + fallbackDeploy: Deploy; +} + +export interface IBuildCsprTransferParams { + network: CasperNetwork; + senderPublicKeyHex: string; + recipientPublicKeyHex: string; + /** Motes. */ + transferAmountMotes: string; + memo?: string; + timestamp?: string; + gasPrice?: number; +} + +export const buildCsprTransferTransactions = ( + { + network, + senderPublicKeyHex, + recipientPublicKeyHex, + transferAmountMotes, + memo, + timestamp, + gasPrice = 1, + }: IBuildCsprTransferParams, + casperNetworkApiVersion: string, +): IBuiltCasperTransaction => { + const chainName = CasperSdkNetworkName[network]; + + const transaction = makeCsprTransferTransaction({ + chainName, + memo, + recipientPublicKeyHex, + senderPublicKeyHex, + transferAmount: transferAmountMotes, + timestamp, + casperNetworkApiVersion, + gasPrice, + }); + + // required for old Ledger apps + const fallbackDeploy = makeCsprTransferDeploy({ + chainName, + memo, + recipientPublicKeyHex, + senderPublicKeyHex, + transferAmount: transferAmountMotes, + timestamp, + }); + + return { transaction, fallbackDeploy }; +}; + +export interface IBuildCep18TransferParams { + network: CasperNetwork; + contractPackageHash: string; + senderPublicKeyHex: string; + recipientPublicKeyHex: string; + /** Motes (token's own decimals). */ + transferAmountMotes: string; + /** Motes (CSPR). */ + paymentAmountMotes: string; + timestamp?: string; + gasPrice?: number; +} + +export const buildCep18TransferTransactions = ( + { + network, + contractPackageHash, + senderPublicKeyHex, + recipientPublicKeyHex, + transferAmountMotes, + paymentAmountMotes, + timestamp, + gasPrice = 1, + }: IBuildCep18TransferParams, + casperNetworkApiVersion: string, +): IBuiltCasperTransaction => { + const chainName = CasperSdkNetworkName[network]; + + const transaction = makeCep18TransferTransaction({ + chainName, + contractPackageHash, + paymentAmount: paymentAmountMotes, + recipientPublicKeyHex, + senderPublicKeyHex, + transferAmount: transferAmountMotes, + timestamp, + casperNetworkApiVersion, + gasPrice, + }); + + // required for old Ledger apps + const fallbackDeploy = makeCep18TransferDeploy({ + chainName, + contractPackageHash, + paymentAmount: paymentAmountMotes, + recipientPublicKeyHex, + senderPublicKeyHex, + transferAmount: transferAmountMotes, + timestamp, + }); + + return { transaction, fallbackDeploy }; +}; + +export interface IBuildNftTransferParams { + network: CasperNetwork; + contractPackageHash: string; + nftStandard: NftStandard; + senderPublicKeyHex: string; + recipientPublicKeyHex: string; + /** Motes (CSPR). */ + paymentAmountMotes: string; + tokenId?: string; + tokenHash?: string; + timestamp?: string; + gasPrice?: number; +} + +export const buildNftTransferTransactions = ( + { + network, + contractPackageHash, + nftStandard, + senderPublicKeyHex, + recipientPublicKeyHex, + paymentAmountMotes, + tokenId, + tokenHash, + timestamp, + gasPrice = 1, + }: IBuildNftTransferParams, + casperNetworkApiVersion: string, +): IBuiltCasperTransaction => { + const chainName = CasperSdkNetworkName[network]; + const sdkStandard = NftStandardToSdkStandardMap[nftStandard]; + + const transaction = makeNftTransferTransaction({ + chainName, + contractPackageHash, + nftStandard: sdkStandard, + paymentAmount: paymentAmountMotes, + recipientPublicKeyHex, + senderPublicKeyHex, + tokenId, + tokenHash, + timestamp, + casperNetworkApiVersion, + gasPrice, + }); + + // required for old Ledger apps + const fallbackDeploy = makeNftTransferDeploy({ + chainName, + contractPackageHash, + nftStandard: sdkStandard, + paymentAmount: paymentAmountMotes, + recipientPublicKeyHex, + senderPublicKeyHex, + tokenId, + tokenHash, + timestamp, + gasPrice, + }); + + return { transaction, fallbackDeploy }; +}; + +export interface IBuildAuctionManagerParams { + network: CasperNetwork; + entryPoint: AuctionManagerEntryPointType; + delegatorPublicKeyHex: string; + validatorPublicKeyHex: string; + newValidatorPublicKeyHex?: string; + /** Motes (CSPR). */ + amountMotes: string; + /** Motes (CSPR). */ + paymentAmountMotes: string; + timestamp?: string; + gasPrice?: number; +} + +export const buildAuctionManagerTransactions = ( + { + network, + entryPoint, + delegatorPublicKeyHex, + validatorPublicKeyHex, + newValidatorPublicKeyHex, + amountMotes, + paymentAmountMotes, + timestamp, + gasPrice = 1, + }: IBuildAuctionManagerParams, + casperNetworkApiVersion: string, +): IBuiltCasperTransaction => { + const chainName = CasperSdkNetworkName[network]; + const contractEntryPoint = AuctionManagerEntryPointMap[entryPoint]; + + const transaction = makeAuctionManagerTransaction({ + amount: amountMotes, + paymentAmount: paymentAmountMotes, + chainName: chainName as CasperNetworkName, + contractEntryPoint, + delegatorPublicKeyHex, + newValidatorPublicKeyHex, + validatorPublicKeyHex, + timestamp, + casperNetworkApiVersion, + gasPrice, + }); + + // required for old Ledger apps + const fallbackDeploy = makeAuctionManagerDeploy({ + amount: amountMotes, + paymentAmount: paymentAmountMotes, + chainName: chainName as CasperNetworkName, + contractEntryPoint, + delegatorPublicKeyHex, + newValidatorPublicKeyHex, + validatorPublicKeyHex, + timestamp, + }); + + return { transaction, fallbackDeploy }; +}; diff --git a/src/utils/casperSdk/validation.test.ts b/src/utils/casperSdk/validation.test.ts new file mode 100644 index 0000000..15dc4ff --- /dev/null +++ b/src/utils/casperSdk/validation.test.ts @@ -0,0 +1,27 @@ +import { KeyAlgorithm, PrivateKey, PublicKey } from 'casper-js-sdk'; +import { isValidCasperPublicKey } from './validation'; + +describe('isValidCasperPublicKey', () => { + it('accepts real ed25519 and secp256k1 keys', () => { + const ed = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); + const secp = PrivateKey.generate(KeyAlgorithm.SECP256K1).publicKey.toHex(); + expect(isValidCasperPublicKey(ed)).toBe(true); + expect(isValidCasperPublicKey(secp)).toBe(true); + }); + + it('rejects wrong length / non-hex / empty', () => { + expect(isValidCasperPublicKey('01' + 'a'.repeat(62))).toBe(false); + expect(isValidCasperPublicKey('zz' + 'a'.repeat(64))).toBe(false); + expect(isValidCasperPublicKey('')).toBe(false); + }); + + it('returns false, without throwing, when the correctly-shaped hex is rejected by the SDK', () => { + // `PublicKey.fromHex` has no reachable curve-rejection input, so the throwing branch is + // forced directly. + jest.spyOn(PublicKey, 'fromHex').mockImplementationOnce(() => { + throw new Error('not a point on the curve'); + }); + const wellFormed = PrivateKey.generate(KeyAlgorithm.SECP256K1).publicKey.toHex(); + expect(isValidCasperPublicKey(wellFormed)).toBe(false); + }); +}); diff --git a/src/utils/casperSdk/validation.ts b/src/utils/casperSdk/validation.ts new file mode 100644 index 0000000..aadfdac --- /dev/null +++ b/src/utils/casperSdk/validation.ts @@ -0,0 +1,26 @@ +import { PublicKey } from 'casper-js-sdk'; + +const ED25519_KEY_ALGO_PREFIX = '01'; +const SECP256K1_KEY_ALGO_PREFIX = '02'; +const PUBLIC_KEY_REG_EXP = /^[a-fA-F0-9]*$/; + +export const isValidCasperPublicKey = (publicKey: string): boolean => { + if (!publicKey || !PUBLIC_KEY_REG_EXP.test(publicKey)) { + return false; + } + + const prefix = publicKey.slice(0, 2); + if ( + (prefix === ED25519_KEY_ALGO_PREFIX && publicKey.length !== 66) || + (prefix === SECP256K1_KEY_ALGO_PREFIX && publicKey.length !== 68) + ) { + return false; + } + + try { + PublicKey.fromHex(publicKey).toHex(false); + return true; + } catch { + return false; + } +}; diff --git a/src/utils/common.property.test.ts b/src/utils/common.property.test.ts index bbbe87c..c55fa55 100644 --- a/src/utils/common.property.test.ts +++ b/src/utils/common.property.test.ts @@ -1,16 +1,39 @@ +import Big from 'big.js'; import fc from 'fast-check'; import Decimal from 'decimal.js'; -import { formatFiatAmountToTokenAmount, formatFiatBalance, getDecimalTokenBalance } from './common'; +import { + formatFiatAmountToTokenAmount, + formatFiatBalance, + getBlockchainAmount, + getDecimalTokenBalance, +} from './common'; const bigIntString = (min: bigint, max: bigint) => fc.bigInt({ min, max }).map(String); -// decimal.js defaults to 20 significant digits of precision. Bounding integer -// balances to 10^18 keeps every value well inside that limit so round-trip -// equality holds without precision loss. -const MAX_INTEGER_BALANCE = 10n ** 18n; +const MAX_INTEGER_BALANCE = 10n ** 30n; + +const referenceToDecimal = (balance: string, decimals: number) => + new Big(balance).div(new Big(10).pow(decimals)).toFixed(); + +const referenceToRaw = (decimalAmount: string, decimals: number) => + new Big(decimalAmount).times(new Big(10).pow(decimals)).round(0, Big.roundDown).toFixed(0); describe('getDecimalTokenBalance (property)', () => { + it('matches the big.js reference', () => { + fc.assert( + fc.property( + bigIntString(-MAX_INTEGER_BALANCE, MAX_INTEGER_BALANCE), + fc.integer({ min: 0, max: 18 }), + (balance, decimals) => { + expect(getDecimalTokenBalance(balance, decimals)).toBe( + referenceToDecimal(balance, decimals), + ); + }, + ), + ); + }); + it('round-trip: result * 10^decimals === balance', () => { fc.assert( fc.property( @@ -18,8 +41,8 @@ describe('getDecimalTokenBalance (property)', () => { fc.integer({ min: 0, max: 18 }), (balance, decimals) => { const result = getDecimalTokenBalance(balance, decimals); - const restored = new Decimal(result).mul(new Decimal(10).pow(decimals)); - expect(restored.eq(new Decimal(balance))).toBe(true); + const restored = new Big(result).times(new Big(10).pow(decimals)); + expect(restored.toFixed()).toBe(new Big(balance).toFixed()); }, ), ); @@ -41,9 +64,9 @@ describe('getDecimalTokenBalance (property)', () => { (positiveBalance, decimals) => { const positive = getDecimalTokenBalance(positiveBalance, decimals); const negative = getDecimalTokenBalance('-' + positiveBalance, decimals); - expect(new Decimal(positive).isPositive()).toBe(true); + expect(new Big(positive).gt(0)).toBe(true); expect(negative.startsWith('-')).toBe(true); - expect(new Decimal(negative).neg().eq(new Decimal(positive))).toBe(true); + expect(new Big(negative).neg().toFixed()).toBe(new Big(positive).toFixed()); }, ), ); @@ -55,9 +78,44 @@ describe('getDecimalTokenBalance (property)', () => { bigIntString(-MAX_INTEGER_BALANCE, MAX_INTEGER_BALANCE), fc.integer({ min: 1, max: 18 }), (balance, decimals) => { - const shifted = new Decimal(getDecimalTokenBalance(balance, decimals)).mul(10); - const lowerDecimals = new Decimal(getDecimalTokenBalance(balance, decimals - 1)); - expect(shifted.eq(lowerDecimals)).toBe(true); + const shifted = new Big(getDecimalTokenBalance(balance, decimals)).times(10); + const lowerDecimals = new Big(getDecimalTokenBalance(balance, decimals - 1)); + expect(shifted.toFixed()).toBe(lowerDecimals.toFixed()); + }, + ), + ); + }); +}); + +describe('getBlockchainAmount (property)', () => { + it('matches the big.js reference', () => { + fc.assert( + fc.property( + bigIntString(-MAX_INTEGER_BALANCE, MAX_INTEGER_BALANCE), + fc.integer({ min: 0, max: 18 }), + (balance, decimals) => { + const decimalAmount = referenceToDecimal(balance, decimals); + + expect(getBlockchainAmount(decimalAmount, decimals)).toBe( + referenceToRaw(decimalAmount, decimals), + ); + }, + ), + ); + }); + + it('truncates a fraction longer than decimals instead of rounding up', () => { + fc.assert( + fc.property( + bigIntString(1n, MAX_INTEGER_BALANCE), + fc.integer({ min: 1, max: 18 }), + fc.integer({ min: 1, max: 6 }), + (integerPart, decimals, extraDigits) => { + const decimalAmount = `${integerPart}.${'9'.repeat(decimals + extraDigits)}`; + + expect(getBlockchainAmount(decimalAmount, decimals)).toBe( + `${integerPart}${'9'.repeat(decimals)}`, + ); }, ), ); @@ -79,11 +137,10 @@ describe('formatFiatBalance (property)', () => { ); }); - it('truthy balance rounding below 0.01 returns "<$0.01"', () => { + it('any truthy balance under a cent returns "<$0.01"', () => { fc.assert( fc.property( - // (0, 0.005) — Decimal.js HALF_UP at 2 places rounds these to 0.00, so amount < 0.01. - fc.double({ min: 1e-9, max: 0.00499, noNaN: true, noDefaultInfinity: true }), + fc.double({ min: 1e-9, max: 0.00999, noNaN: true, noDefaultInfinity: true }), balance => { expect(formatFiatBalance(balance)).toBe('<$0.01'); }, diff --git a/src/utils/common.test.ts b/src/utils/common.test.ts index c49b299..4e8723c 100644 --- a/src/utils/common.test.ts +++ b/src/utils/common.test.ts @@ -51,6 +51,43 @@ describe('common utils', () => { it('formats normal amounts with US grouping', () => { expect(formatFiatBalance('1234.5')).toBe('$1,234.5'); }); + + it('keeps the one-cent bound whatever decimals is set to', () => { + expect(formatFiatBalance('0.005', undefined, 4)).toBe('<$0.01'); + expect(formatFiatBalance('0.011', undefined, 4)).toBe('$0.011'); + }); + + it('bounds on the actual amount, not on the amount rounded to decimals', () => { + expect(formatFiatBalance('0.005')).toBe('<$0.01'); + expect(formatFiatBalance('0.0099')).toBe('<$0.01'); + expect(formatFiatBalance('0.01')).toBe('$0.01'); + }); + + it('shows the bound in the requested currency', () => { + expect(formatFiatBalance('0.005', undefined, 2, { currencyCode: 'EUR' })).toBe('<€0.01'); + }); + + it('keeps the one-cent bound legible at whole-currency decimals', () => { + expect(formatFiatBalance('0.005', undefined, 0)).toBe('<$0.01'); + expect(formatFiatBalance('0.005', undefined, 1)).toBe('<$0.01'); + }); + + it('does not throw when minFractionDigits exceeds decimals', () => { + expect(formatFiatBalance('5', null, 0, { minFractionDigits: 2 })).toBe('$5'); + expect(formatFiatBalance('5', null, 2, { minFractionDigits: 2 })).toBe('$5.00'); + }); + + it('formats in the requested currency, padding to minFractionDigits', () => { + expect(formatFiatBalance('5', null, 2, { currencyCode: 'EUR', minFractionDigits: 2 })).toBe( + '€5.00', + ); + }); + + it('renders an absent balance in the requested currency when the default is null', () => { + expect(formatFiatBalance('', null, 2, { currencyCode: 'EUR', minFractionDigits: 2 })).toBe( + '€0.00', + ); + }); }); describe('getDecimalTokenBalance', () => { @@ -62,6 +99,17 @@ describe('common utils', () => { it('handles 0', () => { expect(getDecimalTokenBalance('0', 9)).toBe('0'); }); + + it('keeps digits past the default Decimal precision', () => { + expect(getDecimalTokenBalance('123456789012345678901234', 9)).toBe( + '123456789012345.678901234', + ); + }); + + it('returns the fallback instead of throwing when one is given', () => { + expect(() => getDecimalTokenBalance('not-a-number', 9)).toThrow(); + expect(getDecimalTokenBalance('not-a-number', 9, '0')).toBe('0'); + }); }); describe('formatTokenBalance', () => { @@ -134,9 +182,21 @@ describe('common utils', () => { expect(getBlockchainAmount('2.5', 9)).toBe('2500000000'); }); + it('truncates rather than rounding up', () => { + expect(getBlockchainAmount('1.9999999999', 9)).toBe('1999999999'); + }); + + it('keeps digits past the default Decimal precision', () => { + expect(getBlockchainAmount('123456789012345.678901234', 9)).toBe('123456789012345678901234'); + }); + it('throws on invalid amount', () => { expect(() => getBlockchainAmount('not-a-number', 9)).toThrow(); }); + + it('returns the fallback instead of throwing when one is given', () => { + expect(getBlockchainAmount('not-a-number', 9, '0')).toBe('0'); + }); }); describe('formatFiatAmount', () => { diff --git a/src/utils/common.ts b/src/utils/common.ts index 2c4665e..d0fab0d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -3,32 +3,78 @@ import Decimal from 'decimal.js'; import { v4 } from 'uuid'; import { FIAT_DECIMALS, HIGH_STAKE_THRESHOLD, IValidator, TOKEN_DISPLAY_DECIMALS } from '../domain'; import { Maybe } from '../typings'; +import { shiftDecimal } from './decimal'; export const noop = () => undefined; export const capitalizeFirstLetter = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); +export interface IFormatFiatBalanceOptions { + /** ISO 4217 code. Defaults to USD. */ + currencyCode?: string; + /** Pads short amounts — 2 renders `$5` as `$5.00`. Clamped to at most `decimals`. */ + minFractionDigits?: number; +} + +const MIN_DISPLAYED_FIAT_AMOUNT = new Decimal('0.01'); + +/** The sub-cent label needs two places to say "one cent" at all, whatever `decimals` is. */ +const MIN_DISPLAYED_FIAT_DECIMALS = 2; + +/** + * Format a fiat amount. Anything under one cent renders as `<$0.01`, whatever `decimals` is. + * + * `defaultBalance` covers an absent balance; pass `null` to render zero in `currencyCode` + * instead of a fixed label. + */ export const formatFiatBalance = ( balance?: string | number, - defaultBalance = '$0.00', + defaultBalance: Maybe = '$0.00', decimals = FIAT_DECIMALS, + { currencyCode = 'USD', minFractionDigits = 0 }: IFormatFiatBalanceOptions = {}, ): string => { + const format = (value: Decimal, maxFractionDigits = decimals): string => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + // `Intl.NumberFormat` throws a `RangeError` when the minimum exceeds the maximum. + minimumFractionDigits: Math.min(minFractionDigits, maxFractionDigits), + maximumFractionDigits: maxFractionDigits, + }).format(value.toNumber()); + if (!balance) { - return defaultBalance; + return defaultBalance ?? format(new Decimal(0)); } - const amount = new Decimal(balance).toDecimalPlaces(decimals).toNumber(); + const amount = new Decimal(balance); - if (amount < 0.01) { - return '<$0.01'; + if (amount.lt(MIN_DISPLAYED_FIAT_AMOUNT)) { + return `<${format(MIN_DISPLAYED_FIAT_AMOUNT, Math.max(decimals, MIN_DISPLAYED_FIAT_DECIMALS))}`; } - return `$${amount.toLocaleString('en-US', { maximumFractionDigits: decimals })}`; + return format(amount.toDecimalPlaces(decimals)); }; -export const getDecimalTokenBalance = (balance: string | number, decimals: number) => { - return new Decimal(balance).div(new Decimal(10).pow(decimals)).toFixed(); +/** + * Raw base units (motes) to a decimal string: `balance / 10 ^ decimals`. + * + * Pass `defaultBalance` to receive it instead of a throw when `balance` cannot be parsed. + */ +export const getDecimalTokenBalance = ( + balance: string | number, + decimals: number, + defaultBalance?: string, +): string => { + try { + return shiftDecimal(balance, -decimals).toFixed(); + } catch (error) { + if (defaultBalance === undefined) { + throw error; + } + + return defaultBalance; + } }; export const formatTokenBalance = ( @@ -94,12 +140,29 @@ export const isKeysEqual = (keyOne?: Maybe, keyTwo?: Maybe) => { return keyOne.toLowerCase() === keyTwo.toLowerCase(); }; -export const getBlockchainAmount = (decimalAmount: string, decimals: number) => { - if (!decimalAmount || Number.isNaN(parseFloat(decimalAmount))) { - throw new Error('Invalid amount'); +/** + * Decimal string to raw base units (motes): `decimalAmount * 10 ^ decimals`, truncated. + * + * Pass `defaultAmount` to receive it instead of a throw when `decimalAmount` is not a number. + */ +export const getBlockchainAmount = ( + decimalAmount: string, + decimals: number, + defaultAmount?: string, +): string => { + try { + if (!decimalAmount || Number.isNaN(parseFloat(decimalAmount))) { + throw new Error('Invalid amount'); + } + + return shiftDecimal(decimalAmount, decimals).toFixed(0, Decimal.ROUND_DOWN); + } catch (error) { + if (defaultAmount === undefined) { + throw error; + } + + return defaultAmount; } - - return new Decimal(decimalAmount).mul(new Decimal(10).pow(decimals)).toFixed(0); }; export const formatFiatAmount = ( diff --git a/src/utils/date.test.ts b/src/utils/date.test.ts index c214289..764f519 100644 --- a/src/utils/date.test.ts +++ b/src/utils/date.test.ts @@ -82,7 +82,7 @@ describe('date utils', () => { startAt: '2024-01-01T00:00:00.000Z', endAt: '2099-01-01T00:00:00.000Z', url: '', - image_url: null, + imageUrl: null, ...overrides, }) as IAppMarketingEvent; diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts new file mode 100644 index 0000000..5e23ba1 --- /dev/null +++ b/src/utils/decimal.ts @@ -0,0 +1,7 @@ +import Decimal from 'decimal.js'; + +export const AmountDecimal = Decimal.clone({ precision: 50, toExpNeg: -50, toExpPos: 50 }); + +/** `value * 10 ^ exponent`, exact for an input of any size. */ +export const shiftDecimal = (value: Decimal.Value, exponent: number): Decimal => + new Decimal(`${new Decimal(value).toFixed()}e${exponent}`); diff --git a/src/utils/index.ts b/src/utils/index.ts index 07014f9..42dab00 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +export * from './amounts'; +export * from './swap'; export * from './date'; export * from './common'; export * from './crypto'; @@ -7,3 +9,4 @@ export * from './deploy'; export * from './logger'; export * from './signatureRequest'; export * from './eip712'; +export * from './transactions'; diff --git a/src/utils/swap.test.ts b/src/utils/swap.test.ts new file mode 100644 index 0000000..b5eea46 --- /dev/null +++ b/src/utils/swap.test.ts @@ -0,0 +1,285 @@ +import { LedgerError, LedgerEventStatus } from '../domain/ledger'; +import type { IDexToken } from '../domain/swap/entities'; +import { SwapQuoteType } from '../domain/swap/entities'; +import { + calculateMaxUsableBalance, + calculateSwapFee, + calculateSwapMaxSlippage, + calculateSwapPaymentAmount, + calculateSwapRate, + calculateTokenFiatAmount, + clampDeadlineValue, + clampSlippageValue, + getErrorMessageDescription, + getMillisecondsUntilNextBlock, + getSwapRoutes, + getTransactionErrorMessage, + handleTokenSelection, +} from './swap'; + +const WCSPR_HASH = '1111111111111111111111111111111111111111111111111111111111111111'.slice(0, 64); + +const buildToken = (overrides: Partial = {}): IDexToken => ({ + id: 'some-package-hash', + name: 'Some Token', + symbol: 'TOK', + icon: null, + decimals: 9, + packageHash: 'some-package-hash', + isWhitelisted: true, + isBlacklisted: false, + fiatRates: null, + totalValueLocked: null, + volume24h: null, + ...overrides, +}); + +const csprToken = buildToken({ + id: 'cspr', + name: 'Casper', + symbol: 'CSPR', + packageHash: WCSPR_HASH, + decimals: 9, +}); + +describe('swap', () => { + describe('calculateSwapRate', () => { + it('computes the rate for ExactIn', () => { + expect(calculateSwapRate('2000000000', 9, '1000000000', 9, SwapQuoteType.ExactIn)).toBe( + '0.5', + ); + }); + + it('computes the rate for ExactOut', () => { + expect(calculateSwapRate('2000000000', 9, '1000000000', 9, SwapQuoteType.ExactOut)).toBe('2'); + }); + }); + + describe('calculateSwapFee', () => { + it('defaults to the 0.3% protocol fee', () => { + expect(calculateSwapFee('1000')).toBe('3'); + }); + + it('returns "0" for empty input', () => { + expect(calculateSwapFee('')).toBe('0'); + }); + }); + + describe('calculateSwapMaxSlippage', () => { + it('converts bps to a percentage string', () => { + expect(calculateSwapMaxSlippage('50')).toBe('0.50'); + }); + + it('returns null when slippageBps is undefined', () => { + expect(calculateSwapMaxSlippage(undefined)).toBeNull(); + }); + }); + + describe('getMillisecondsUntilNextBlock', () => { + it('computes the remaining time until the next block', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:03.000Z')); + + const lastBlockTimestamp = new Date('2026-01-01T00:00:00.000Z').getTime(); + + expect(getMillisecondsUntilNextBlock(lastBlockTimestamp)).toBe(5500); + + jest.useRealTimers(); + }); + }); + + describe('getSwapRoutes', () => { + it('displays the WCSPR leg as the synthetic native CSPR token by default', () => { + expect(getSwapRoutes([WCSPR_HASH], [csprToken], WCSPR_HASH)).toEqual([csprToken]); + }); + + it('displays the WCSPR leg as itself when wcsprDisplay is "wrapped"', () => { + const [route] = getSwapRoutes([WCSPR_HASH], [csprToken], WCSPR_HASH, { + wcsprDisplay: 'wrapped', + }); + + expect(route).toMatchObject({ id: WCSPR_HASH, symbol: 'WCSPR', name: 'Wrapped Casper' }); + }); + + it('filters out hashes that have no matching token', () => { + expect(getSwapRoutes(['unknown-hash'], [csprToken], WCSPR_HASH)).toEqual([]); + }); + + it('matches token package hashes case-insensitively', () => { + const token = buildToken({ id: 'abc', packageHash: 'ABCDEF' }); + + expect(getSwapRoutes(['abcdef'], [token], WCSPR_HASH)).toEqual([token]); + }); + }); + + describe('getErrorMessageDescription', () => { + it('maps known error codes to copy', () => { + expect(getErrorMessageDescription('not_found')).toBe( + 'Please select a different trading pair.', + ); + expect(getErrorMessageDescription('invalid_input')).toBe( + 'Please provide a higher input amount for a trade.', + ); + }); + + it('returns null when code is missing', () => { + expect(getErrorMessageDescription(null)).toBeNull(); + }); + }); + + describe('calculateTokenFiatAmount', () => { + it('returns "" when the token is null', () => { + expect(calculateTokenFiatAmount(null, '1', 'USD')).toBe(''); + }); + + it('uses csprFiatRate for the native CSPR token', () => { + expect(calculateTokenFiatAmount(csprToken, '100', 'USD', null, 0.05)).toBe('$5.00'); + }); + + it('shows a sub-cent amount as a bound', () => { + const token = buildToken(); + + expect(calculateTokenFiatAmount(token, '0.005', 'USD', 1)).toBe('<$0.01'); + }); + + it('shows the bound in the requested currency', () => { + const token = buildToken(); + + expect(calculateTokenFiatAmount(token, '', 'EUR', 1)).toBe('<€0.01'); + }); + + it('returns "N/A" when there is no fiat rate', () => { + const token = buildToken(); + + expect(calculateTokenFiatAmount(token, '1', 'USD', null)).toBe('N/A'); + }); + }); + + describe('calculateSwapPaymentAmount', () => { + it('returns null if either token is null', () => { + expect(calculateSwapPaymentAmount(null, buildToken(), null, 'USD')).toBeNull(); + expect(calculateSwapPaymentAmount(csprToken, null, null, 'USD')).toBeNull(); + }); + + it('falls back to "30 CSPR" when there is no fiat rate', () => { + expect(calculateSwapPaymentAmount(csprToken, buildToken(), null, 'USD')).toBe('30 CSPR'); + }); + + it('converts to fiat when a csprFiatRate is given', () => { + const token1 = buildToken({ id: 'a', packageHash: 'a' }); + const token2 = buildToken({ id: 'b', packageHash: 'b' }); + + expect(calculateSwapPaymentAmount(token1, token2, 0.1, 'USD')).toBe('$3.00'); + }); + }); + + describe('calculateMaxUsableBalance', () => { + it('returns the input balance for non-CSPR tokens', () => { + expect(calculateMaxUsableBalance({ balance: '12.5', symbol: 'TOK', context: 'swap' })).toBe( + '12.5', + ); + }); + + it('reserves approve + swap CSPR for the swap context', () => { + expect(calculateMaxUsableBalance({ balance: '100', symbol: 'CSPR', context: 'swap' })).toBe( + '65', + ); + }); + + it('floors at "0" when the reserve exceeds the balance in the swap context', () => { + expect(calculateMaxUsableBalance({ balance: '30', symbol: 'CSPR', context: 'swap' })).toBe( + '0', + ); + }); + + it('reserves the wrap payment for the wrap context', () => { + expect(calculateMaxUsableBalance({ balance: '100', symbol: 'CSPR', context: 'wrap' })).toBe( + '95', + ); + }); + + it('floors at "0" when the reserve exceeds the balance in the wrap context', () => { + expect(calculateMaxUsableBalance({ balance: '5', symbol: 'CSPR', context: 'wrap' })).toBe( + '0', + ); + }); + }); + + describe('getTransactionErrorMessage', () => { + it('extracts the message from an Error instance', () => { + expect(getTransactionErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('returns a bare string as-is', () => { + expect(getTransactionErrorMessage('bare message')).toBe('bare message'); + }); + + it('extracts message from a plain object', () => { + expect(getTransactionErrorMessage({ message: 'plain object message' })).toBe( + 'plain object message', + ); + }); + + it('falls back to the default message', () => { + expect(getTransactionErrorMessage({})).toBe('Transaction failed'); + }); + + it('falls back to a custom fallback', () => { + expect(getTransactionErrorMessage({}, 'Custom fallback')).toBe('Custom fallback'); + }); + + it('renders a LedgerError as its device status, never its JSON payload', () => { + const error = new LedgerError({ + status: LedgerEventStatus.SignatureCanceled, + publicKey: '02abc', + txHash: 'deadbeef', + } as never); + + expect(getTransactionErrorMessage(error)).toBe(LedgerEventStatus.SignatureCanceled); + expect(getTransactionErrorMessage(error)).not.toContain('02abc'); + }); + }); + + describe('handleTokenSelection', () => { + it('swaps positions when selecting the token already in the other slot', () => { + const first = buildToken({ id: 'first', packageHash: 'first' }); + const second = buildToken({ id: 'second', packageHash: 'second' }); + + // `first` is already selected in the 'first' slot; picking it for 'second' should + // swap the two positions rather than duplicate the token. + expect(handleTokenSelection({ first, second }, first, 'second')).toEqual({ + first: second, + second: first, + }); + }); + + it('sets the token in the current position otherwise', () => { + const first = buildToken({ id: 'first', packageHash: 'first' }); + const second = buildToken({ id: 'second', packageHash: 'second' }); + + expect(handleTokenSelection({ first: null, second: null }, first, 'first')).toEqual({ + first, + second: null, + }); + expect(handleTokenSelection({ first, second: null }, second, 'second')).toEqual({ + first, + second, + }); + }); + }); + + describe('clampSlippageValue', () => { + it('clamps to the [0.01, 50] range', () => { + expect(clampSlippageValue(0.001)).toBe(0.01); + expect(clampSlippageValue(51)).toBe(50); + expect(clampSlippageValue(NaN)).toBe(0.01); + }); + }); + + describe('clampDeadlineValue', () => { + it('clamps to the [1, 120] range', () => { + expect(clampDeadlineValue(0)).toBe(1); + expect(clampDeadlineValue(121)).toBe(120); + expect(clampDeadlineValue(NaN)).toBe(1); + }); + }); +}); diff --git a/src/utils/swap.ts b/src/utils/swap.ts new file mode 100644 index 0000000..1a329bc --- /dev/null +++ b/src/utils/swap.ts @@ -0,0 +1,309 @@ +import { + BLOCK_INTERVAL_MS, + CSPR_DECIMALS, + CSPR_NATIVE_TOKEN_ID, + DEX_PAYMENT_AMOUNT, + FIAT_DECIMALS, + MAX_DEADLINE, + MAX_SLIPPAGE, + MIN_DEADLINE, + MIN_SLIPPAGE, + NO_FIAT_RATE_LABEL, + POSSIBLE_QUOTE_LATENCY_MS, + SWAP_PROTOCOL_FEE, + TOKEN_DISPLAY_DECIMALS, +} from '../domain/constants'; +import { LedgerError } from '../domain/ledger/errors'; +import type { IDexToken } from '../domain/swap/entities'; +import { SwapQuoteType } from '../domain/swap/entities'; +import { + formatFiatBalance, + formatTokenBalance, + getBlockchainAmount, + getDecimalTokenBalance, +} from './common'; +import { AmountDecimal as D } from './decimal'; + +export type TokenPosition = 'first' | 'second'; +export type WcsprDisplay = 'wrapped' | 'native'; + +const formatDecimalAmount = (amount: string, maxDecimals = TOKEN_DISPLAY_DECIMALS): string => + formatTokenBalance(amount, 0, maxDecimals, '0', true); + +export const calculateSwapRate = ( + token1amount: string | null, + token1decimal: number | null, + token2amount: string | null, + token2decimal: number | null, + quoteType: SwapQuoteType, +): string => { + const token1 = new D(getDecimalTokenBalance(token1amount ?? '0', token1decimal ?? 0)); + const token2 = new D(getDecimalTokenBalance(token2amount ?? '0', token2decimal ?? 0)); + + const [numerator, denominator] = + quoteType === SwapQuoteType.ExactIn ? [token2, token1] : [token1, token2]; + + return formatDecimalAmount(numerator.div(denominator).toFixed(10), 10); +}; + +export const calculateSwapFee = ( + decimalAmount: string, + fee: number = SWAP_PROTOCOL_FEE, +): string => { + if (!decimalAmount) { + return '0'; + } + + return formatDecimalAmount(new D(decimalAmount).mul(fee).toFixed(6)); +}; + +export const calculateSwapMaxSlippage = (slippageBps?: string): string | null => { + if (!slippageBps) { + return null; + } + + return new D(slippageBps).div(100).toFixed(2); +}; + +export const getMillisecondsUntilNextBlock = (lastBlockTimestamp: number): number => { + const now = Date.now(); + const elapsedTime = now - lastBlockTimestamp; + const remainderInCurrentPeriod = elapsedTime % BLOCK_INTERVAL_MS; + + return BLOCK_INTERVAL_MS - remainderInCurrentPeriod + POSSIBLE_QUOTE_LATENCY_MS; +}; + +// The token DTO maps the WCSPR API record to the synthetic CSPR token, so the real on-chain +// identity has to be rebuilt here for views that must link to the WCSPR contract page. +const buildWcsprAsItself = ( + csprToken: IDexToken | undefined, + wrappedCsprPackageHash: string, +): IDexToken => ({ + id: wrappedCsprPackageHash, + name: 'Wrapped Casper', + symbol: 'WCSPR', + icon: csprToken?.icon ?? null, + decimals: csprToken?.decimals ?? 9, + packageHash: wrappedCsprPackageHash, + isWhitelisted: true, + isBlacklisted: false, + fiatRates: csprToken?.fiatRates ?? null, + totalValueLocked: null, + volume24h: null, +}); + +/** + * `wcsprDisplay` selects how a WCSPR leg in the route is presented: + * - 'native' (default): the trade-form UX — show WCSPR as CSPR (id='cspr', symbol='CSPR'). + * - 'wrapped': the real on-chain WCSPR identity, so a historical Swaps row links to the + * actual WCSPR token-details page. + */ +export const getSwapRoutes = ( + routesHashes: string[] = [], + tokens: IDexToken[] = [], + wrappedCsprPackageHash: string, + options: { wcsprDisplay?: WcsprDisplay } = {}, +): IDexToken[] => { + const { wcsprDisplay = 'native' } = options; + + return routesHashes + .map(hash => { + if (hash === wrappedCsprPackageHash) { + const nativeCsprToken = tokens.find(token => token.id === CSPR_NATIVE_TOKEN_ID); + + return wcsprDisplay === 'wrapped' + ? buildWcsprAsItself(nativeCsprToken, wrappedCsprPackageHash) + : nativeCsprToken; + } + + return tokens.find(token => token.packageHash.toLowerCase() === hash.toLowerCase()); + }) + .filter((token): token is IDexToken => Boolean(token)); +}; + +export const getErrorMessageDescription = (code?: string | null): string | null => { + if (!code) { + return null; + } + + if (code.toLowerCase() === 'not_found') { + return 'Please select a different trading pair.'; + } else if (code.toLowerCase() === 'invalid_input') { + return 'Please provide a higher input amount for a trade.'; + } + + return null; +}; + +const formatSwapFiatAmount = (amount: string, currencyCode: string): string => + formatFiatBalance(amount, null, FIAT_DECIMALS, { + currencyCode, + minFractionDigits: FIAT_DECIMALS, + }); + +export const calculateTokenFiatAmount = ( + token: IDexToken | null, + decimalAmount: string, + currencyCode: string, + tokenFiatRate?: number | null, + csprFiatRate?: string | number | null, +): string => { + if (!token) { + return ''; + } + + const fiatRate = token.id === CSPR_NATIVE_TOKEN_ID ? csprFiatRate : tokenFiatRate; + + if (!fiatRate) { + return NO_FIAT_RATE_LABEL; + } + + return formatSwapFiatAmount(new D(decimalAmount || 0).mul(fiatRate).toFixed(), currencyCode); +}; + +export const calculateSwapPaymentAmount = ( + token1: IDexToken | null, + token2: IDexToken | null, + csprFiatRate: string | number | null, + currencyCode: string, +): string | null => { + if (!(token1 && token2)) { + return null; + } + + const paymentInMotes = + token1.id === CSPR_NATIVE_TOKEN_ID || token2.id === CSPR_NATIVE_TOKEN_ID + ? DEX_PAYMENT_AMOUNT.swapCsprForToken + : DEX_PAYMENT_AMOUNT.swapTokenForToken; + + const amount = getDecimalTokenBalance(paymentInMotes, CSPR_DECIMALS); + + return csprFiatRate + ? formatSwapFiatAmount(new D(amount).mul(csprFiatRate).toFixed(), currencyCode) + : `${amount} CSPR`; +}; + +const calculateAvailableCsprBalance = (balance: string, context: 'swap' | 'wrap'): string => { + try { + const balanceInMotes = new D(getBlockchainAmount(balance, CSPR_DECIMALS)); + + const totalPaymentAmount = + context === 'swap' + ? new D(DEX_PAYMENT_AMOUNT.approve).plus(DEX_PAYMENT_AMOUNT.swapCsprForToken) + : new D(DEX_PAYMENT_AMOUNT.wrap); + + const availableBalanceInMotes = balanceInMotes.minus(totalPaymentAmount); + + if (availableBalanceInMotes.lte(0)) { + return '0'; + } + + return getDecimalTokenBalance(availableBalanceInMotes.toFixed(0), CSPR_DECIMALS); + } catch { + return balance; + } +}; + +/** + * Maximum usable balance for a token, reserving gas for the approve + swap (or wrap) payments + * when the token is native CSPR. + */ +export const calculateMaxUsableBalance = (params: { + balance: string; + symbol: string; + context: 'swap' | 'wrap'; +}): string => { + const { balance, symbol, context } = params; + + try { + if (symbol === 'CSPR') { + return calculateAvailableCsprBalance(balance, context); + } + + return balance || '0'; + } catch { + return balance || '0'; + } +}; + +/** + * Normalize an unknown transaction error into a human-readable message. + * + * Failures arrive in several shapes — an Error instance (cancellation / send error), a plain + * `{ message }` object (processed-with-error / expired / timeout callbacks), or a bare string + * — so the UI can show the real reason instead of a generic fallback. + * + * A `LedgerError` returns its device status alone: its `message` is the JSON of the whole event, + * public key and transaction hash included. + */ +export const getTransactionErrorMessage = ( + error: unknown, + fallback = 'Transaction failed', +): string => { + if (error instanceof LedgerError) { + return error.ledgerEvent.status; + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && 'message' in error) { + const { message } = error as { message: unknown }; + + return typeof message === 'string' ? message : fallback; + } + + return fallback; +}; + +export const handleTokenSelection = ( + currentState: { first: IDexToken | null; second: IDexToken | null }, + selectedToken: IDexToken, + activePosition: TokenPosition, +): { first: IDexToken | null; second: IDexToken | null } => { + const otherPosition: TokenPosition = activePosition === 'first' ? 'second' : 'first'; + + // Picking the token that already sits in the other position swaps the two rather than + // leaving it selected twice. + if (currentState[otherPosition]?.id === selectedToken.id) { + return { + ...currentState, + [activePosition]: selectedToken, + [otherPosition]: currentState[activePosition], + }; + } + + return { + ...currentState, + [activePosition]: selectedToken, + }; +}; + +export const clampSlippageValue = (value: number): number => { + if (Number.isNaN(value) || value < MIN_SLIPPAGE) { + return MIN_SLIPPAGE; + } + + if (value > MAX_SLIPPAGE) { + return MAX_SLIPPAGE; + } + + return value; +}; + +export const clampDeadlineValue = (value: number): number => { + if (Number.isNaN(value) || value < MIN_DEADLINE) { + return MIN_DEADLINE; + } + + if (value > MAX_DEADLINE) { + return MAX_DEADLINE; + } + + return value; +}; diff --git a/src/utils/transactions.test.ts b/src/utils/transactions.test.ts new file mode 100644 index 0000000..ad71177 --- /dev/null +++ b/src/utils/transactions.test.ts @@ -0,0 +1,45 @@ +import type { Transaction } from 'casper-js-sdk'; +import { + createCasperMessageBytes, + getPrivateKeyHexFromSecretKey, + isTransactionSignedBy, +} from './transactions'; + +describe('getPrivateKeyHexFromSecretKey', () => { + it('truncates legacy 128-char secrets to 64', () => { + expect(getPrivateKeyHexFromSecretKey('a'.repeat(128))).toBe('a'.repeat(64)); + }); + it('keeps 64-char secrets unchanged', () => { + expect(getPrivateKeyHexFromSecretKey('b'.repeat(64))).toBe('b'.repeat(64)); + }); +}); + +describe('createCasperMessageBytes', () => { + it('prefixes with the Casper message header', () => { + expect(Buffer.from(createCasperMessageBytes('hello')).toString('utf-8')).toBe( + 'Casper Message:\nhello', + ); + }); +}); + +describe('isTransactionSignedBy', () => { + const makeTx = (approvals: unknown): Transaction => ({ approvals }) as unknown as Transaction; + + it('returns false when there are no approvals', () => { + expect(isTransactionSignedBy(makeTx([]), '01aa')).toBe(false); + }); + + it('returns false when approvals is undefined, without throwing', () => { + expect(isTransactionSignedBy(makeTx(undefined), '01aa')).toBe(false); + }); + + it('returns false when approved by a different key', () => { + const tx = makeTx([{ signer: { toString: () => '01bb' } }]); + expect(isTransactionSignedBy(tx, '01aa')).toBe(false); + }); + + it('returns true when approved by the same key, case-insensitively', () => { + const tx = makeTx([{ signer: { toString: () => '01AABB' } }]); + expect(isTransactionSignedBy(tx, '01aabb')).toBe(true); + }); +}); diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts new file mode 100644 index 0000000..76f53ac --- /dev/null +++ b/src/utils/transactions.ts @@ -0,0 +1,18 @@ +// type-only: a value import would pull the sdk into this module's import graph +import type { Transaction } from 'casper-js-sdk'; +import { CASPER_MESSAGE_HEADER } from '../domain/constants'; +import { isKeysEqual } from './common'; + +/** Legacy accounts may store 64-byte (priv+pub) material; the private scalar is the first 32 bytes. */ +export const getPrivateKeyHexFromSecretKey = (secretKeyHex: string): string => + secretKeyHex.substring(0, 64); + +/** + * Prepends the Casper message header and converts to bytes. + * Buffer (not TextEncoder) on purpose — matches both apps and legacy runtimes. + */ +export const createCasperMessageBytes = (message: string): Uint8Array => + Uint8Array.from(Buffer.from(`${CASPER_MESSAGE_HEADER}${message}`)); + +export const isTransactionSignedBy = (tx: Transaction, publicKeyHex: string): boolean => + tx.approvals?.some(approval => isKeysEqual(approval.signer.toString(), publicKeyHex)) ?? false; diff --git a/tsconfig.json b/tsconfig.json index c5d0f3f..bf1e986 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "types": ["node", "jest"], "lib": ["esnext", "dom"], "noEmit": true, + "jsx": "react-jsx", "target": "esnext", "module": "nodenext", "moduleResolution": "nodenext" diff --git a/yarn.lock b/yarn.lock index db26d76..7e6e96a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,7 +16,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -401,6 +401,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + "@babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" @@ -978,6 +985,96 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/devices@npm:8.15.1": + version: 8.15.1 + resolution: "@ledgerhq/devices@npm:8.15.1" + dependencies: + "@ledgerhq/errors": "npm:^6.36.0" + "@ledgerhq/logs": "npm:^6.17.0" + rxjs: "npm:7.8.2" + semver: "npm:7.7.3" + checksum: 10c0/34d4959070465c20f815ae656baca103c24c3fe4e4b7c556975ca43de30ce5420cdcff7ae9f1a3e3fa4ef80cf851160970107557422c8677a208d4af076cc8e1 + languageName: node + linkType: hard + +"@ledgerhq/devices@npm:8.17.0": + version: 8.17.0 + resolution: "@ledgerhq/devices@npm:8.17.0" + dependencies: + semver: "npm:7.7.3" + checksum: 10c0/5029f3d79a09a06f84f69ed479ae628ef42a2b57be8122b110e8eca54a874ccc3e2876bd14f86e732e3306c920fe8a8ad104122cc703387cf6efc5ad2acb2151 + languageName: node + linkType: hard + +"@ledgerhq/devices@npm:8.9.0": + version: 8.9.0 + resolution: "@ledgerhq/devices@npm:8.9.0" + dependencies: + "@ledgerhq/errors": "npm:^6.28.0" + "@ledgerhq/logs": "npm:^6.13.0" + rxjs: "npm:7.8.2" + semver: "npm:7.7.3" + checksum: 10c0/6443ef5551081221c98da7ff175e61a927a20b251d46a78a4d9eb9ee8fa77868f41ac308f7596682f6ad6d87be9d765e499530b434e27343bfbc7e0ae0ed33fd + languageName: node + linkType: hard + +"@ledgerhq/errors@npm:^6.28.0, @ledgerhq/errors@npm:^6.36.0": + version: 6.37.0 + resolution: "@ledgerhq/errors@npm:6.37.0" + checksum: 10c0/8943569bec9640d7af14498491fc254857a3ec3bf21e841cf51b8bfe7fe9192838e1c854dad646c5264e020b1411df5fca26fc720dab31de539f2712ce6ed217 + languageName: node + linkType: hard + +"@ledgerhq/errors@npm:^7.0.0": + version: 7.0.0 + resolution: "@ledgerhq/errors@npm:7.0.0" + checksum: 10c0/975943754576b665824f70f8a3c0d5d1c770799f970d026337b2173e327d675256c9e8af8390aa4ef9664f6fd04d03e91a53ce6311ccf906e4f79e331ddd64d9 + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:6.31.16": + version: 6.31.16 + resolution: "@ledgerhq/hw-transport@npm:6.31.16" + dependencies: + "@ledgerhq/devices": "npm:8.9.0" + "@ledgerhq/errors": "npm:^6.28.0" + "@ledgerhq/logs": "npm:^6.13.0" + events: "npm:^3.3.0" + checksum: 10c0/b98da4bbf842b5beaac09c2550b52d0981ea37f008d64cc1187122ca3623146713aa9d86db62a69e1f0220dbe9a84430c7942d00504d0b096b1a84ecbdd2409b + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:6.35.4": + version: 6.35.4 + resolution: "@ledgerhq/hw-transport@npm:6.35.4" + dependencies: + "@ledgerhq/devices": "npm:8.15.1" + "@ledgerhq/errors": "npm:^6.36.0" + "@ledgerhq/logs": "npm:^6.17.0" + events: "npm:^3.3.0" + checksum: 10c0/07d36556e77287cd07d213496e80c726d797cd5e0b018edbea29b5ebdc3a69acdc2f812f4bb00cb4e79aba446178f573c188eb0b25ad26f3ad9c88fde94bff04 + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:^6.35.4": + version: 6.35.7 + resolution: "@ledgerhq/hw-transport@npm:6.35.7" + dependencies: + "@ledgerhq/devices": "npm:8.17.0" + "@ledgerhq/errors": "npm:^7.0.0" + "@ledgerhq/logs": "npm:^6.17.0" + events: "npm:^3.3.0" + checksum: 10c0/6c3e9ea6ad209832a0bfdcda27093fe7faa1f3ea2fced0b766bf2f9c8f4f3067b911fe0fe7d25932939776e6c712f6c7f3dc50b60c0442eb9ae3a69addd92cd5 + languageName: node + linkType: hard + +"@ledgerhq/logs@npm:^6.13.0, @ledgerhq/logs@npm:^6.17.0": + version: 6.19.0 + resolution: "@ledgerhq/logs@npm:6.19.0" + checksum: 10c0/ef5c33aab2a39276ca066bef7f72ae42962b814645c3715a4dcc385537e51b39b9e930dab56c84834059b7ce5865057db0c94c3a29f2bf57a927141a9adecc0b + languageName: node + linkType: hard + "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1": version: 5.1.1-v1 resolution: "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1" @@ -1079,6 +1176,74 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.90.6": + version: 5.90.6 + resolution: "@tanstack/query-core@npm:5.90.6" + checksum: 10c0/2523cd4d9bc4d9fc1a828b15923c44046004fb1c4322f8c211ff4c153914bc776a356a476f499127cfee3fc89b101ac64f93b352aa761bd349c3a8e0de33d792 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:5.90.6": + version: 5.90.6 + resolution: "@tanstack/react-query@npm:5.90.6" + dependencies: + "@tanstack/query-core": "npm:5.90.6" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/0cde819c41d3f02dccc443786c3aeb6e0a6135365ef127dd8e5bb25d65837b0b12b011c6700c14df1f866a1d65d57a6f4a00abf2ddac31543d68b4963ca80d1a + languageName: node + linkType: hard + +"@testing-library/dom@npm:10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10c0/19ce048012d395ad0468b0dbcc4d0911f6f9e39464d7a8464a587b29707eed5482000dad728f5acc4ed314d2f4d54f34982999a114d2404f36d048278db815b1 + languageName: node + linkType: hard + +"@testing-library/react@npm:16.3.3": + version: 16.3.3 + resolution: "@testing-library/react@npm:16.3.3" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/87d65cca71a39c93fffa23746a751a7037a4b4e939ea6b234af5188e4bdec96a7568b05000db109b5b76c686899e93a329a594d7e690674e30b35594c87c9e1d + languageName: node + linkType: hard + +"@tootallnate/once@npm:2": + version: 2.0.1 + resolution: "@tootallnate/once@npm:2.0.1" + checksum: 10c0/23b01a341485be711c602077936d70f8e695405bb88ab4433dc6d1e6cb4556401518789574d399eded790b70b27738136c9a8f02df7ae4219f4ba28bb22d586b + languageName: node + linkType: hard + +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -1120,6 +1285,13 @@ __metadata: languageName: node linkType: hard +"@types/big.js@npm:^6.2.2": + version: 6.2.2 + resolution: "@types/big.js@npm:6.2.2" + checksum: 10c0/8f8472dfc1ef61c492e6841e86f8b9b97e5b024136bf7964e582a6a80ba73d4dbfd6cc23ed3b9d8fea69c7f30834fffd1c88e7fb981811f5c6ca608380b5ad67 + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.9 resolution: "@types/estree@npm:1.0.9" @@ -1171,6 +1343,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10c0/3d4b2a3eab145674ee6da482607c5e48977869109f0f62560bf91ae1a792c9e847ac7c6aaf243ed2e97333cb3c51aef314ffa54a19ef174b8f9592dfcb836b25 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -1196,6 +1379,32 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:*": + version: 15.7.15 + resolution: "@types/prop-types@npm:15.7.15" + checksum: 10c0/b59aad1ad19bf1733cf524fd4e618196c6c7690f48ee70a327eb450a42aab8e8a063fbe59ca0a5701aebe2d92d582292c0fb845ea57474f6a15f6994b0e260b2 + languageName: node + linkType: hard + +"@types/react-dom@npm:^18": + version: 18.3.7 + resolution: "@types/react-dom@npm:18.3.7" + peerDependencies: + "@types/react": ^18.0.0 + checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e + languageName: node + linkType: hard + +"@types/react@npm:^18.3.0": + version: 18.3.31 + resolution: "@types/react@npm:18.3.31" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.2.2" + checksum: 10c0/44180549dd045f536ececd39e39aacdf828e76adc1c4a90b132f453e23cc370c4648d9102ae401172ebd8fd8b1977a901a39e214e53ec77171b27514b588c179 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -1203,6 +1412,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -1437,6 +1653,25 @@ __metadata: languageName: node linkType: hard +"@zondax/ledger-casper@npm:^2.6.4": + version: 2.6.4 + resolution: "@zondax/ledger-casper@npm:2.6.4" + dependencies: + "@ledgerhq/hw-transport": "npm:6.31.16" + "@zondax/ledger-js": "npm:^1.3.1" + checksum: 10c0/402ffea40377320db4242549f73e747803a11e946037e9bcce080802cdb728ef9ee19aefff0e9540cc116b4f5bf39705955b482e3bfc2fe2683ced30b5a0c4bd + languageName: node + linkType: hard + +"@zondax/ledger-js@npm:^1.3.1": + version: 1.3.4 + resolution: "@zondax/ledger-js@npm:1.3.4" + dependencies: + "@ledgerhq/hw-transport": "npm:6.35.4" + checksum: 10c0/1bfa5d15eb236304648727c35d4e8727095053ab3f5c6f7ceff6e0e43be0c50039d4797eda022d3432a9f6e748abfd7a71d3159428b3580ea9bd2cd9b98c35a1 + languageName: node + linkType: hard + "CasperWalletCore@workspace:.": version: 0.0.0-use.local resolution: "CasperWalletCore@workspace:." @@ -1444,12 +1679,21 @@ __metadata: "@casper-ecosystem/casper-eip-712": "npm:1.2.1" "@eslint/eslintrc": "npm:^3.3.6" "@eslint/js": "npm:^9.39.5" + "@ledgerhq/hw-transport": "npm:^6.35.4" "@noble/hashes": "npm:^1.8.0" "@react-native/eslint-config": "npm:^0.87.1" + "@tanstack/react-query": "npm:5.90.6" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/react": "npm:16.3.3" + "@types/big.js": "npm:^6.2.2" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.20.1" + "@types/react": "npm:^18.3.0" + "@types/react-dom": "npm:^18" + "@zondax/ledger-casper": "npm:^2.6.4" apisauce: "npm:^3.2.2" - casper-js-sdk: "npm:5.1.0" + big.js: "npm:^7.0.1" + casper-js-sdk: "npm:5.1.1" date-fns: "npm:^4.4.0" decimal.js: "npm:^10.6.0" deepmerge: "npm:^4.3.1" @@ -1458,15 +1702,40 @@ __metadata: fast-check: "npm:^4.9.0" husky: "npm:^9.1.7" jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:29.7.0" lint-staged: "npm:^17.4.1" lru-cache: "npm:11.5.2" prettier: "npm:^3.9.5" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + rxjs: "npm:^7.8.2" ts-jest: "npm:^29.4.12" typescript: "npm:^5.9.3" uuid: "npm:^14.0.2" + peerDependencies: + "@ledgerhq/hw-transport": ^6.35.4 + "@tanstack/react-query": ^5 + "@zondax/ledger-casper": ^2.6.4 + react: ">=18" + peerDependenciesMeta: + "@ledgerhq/hw-transport": + optional: true + "@tanstack/react-query": + optional: true + "@zondax/ledger-casper": + optional: true + react: + optional: true languageName: unknown linkType: soft +"abab@npm:^2.0.6": + version: 2.0.6 + resolution: "abab@npm:2.0.6" + checksum: 10c0/0b245c3c3ea2598fe0025abf7cc7bb507b06949d51e8edae5d12c1b847a0a0c09639abcb94788332b4e2044ac4491c1e8f571b51c7826fd4b0bda1685ad4a278 + languageName: node + linkType: hard + "abbrev@npm:^4.0.0": version: 4.0.0 resolution: "abbrev@npm:4.0.0" @@ -1474,6 +1743,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" + dependencies: + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 10c0/7437f58e92d99292dbebd0e79531af27d706c9f272f31c675d793da6c82d897e75302a8744af13c7f7978a8399840f14a353b60cf21014647f71012982456d2b + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -1483,6 +1762,24 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:^8.0.2": + version: 8.3.5 + resolution: "acorn-walk@npm:8.3.5" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/e31bf5b5423ed1349437029d66d708b9fbd1b77a644b031501e2c753b028d13b56348210ed901d5b1d0d86eb3381c0a0fc0d0998511a9d546d1194936266a332 + languageName: node + linkType: hard + +"acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.8.1": + version: 8.18.0 + resolution: "acorn@npm:8.18.0" + bin: + acorn: bin/acorn + checksum: 10c0/be771be2135cc07910cf76f444ad514d7dcfd6d4a8026e597e93155275abc8ef61eee12211d52146e9d962874269b634f397464942087be013c89d0c54c5f8e5 + languageName: node + linkType: hard + "acorn@npm:^8.15.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" @@ -1580,6 +1877,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469 + languageName: node + linkType: hard + "array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" @@ -1819,6 +2125,13 @@ __metadata: languageName: node linkType: hard +"big.js@npm:^7.0.1": + version: 7.0.1 + resolution: "big.js@npm:7.0.1" + checksum: 10c0/2ea2c2e263db37b2d5c6ad6246408eabf13ba07776cfd92bf464db201ef9aa9aa1712accbfe97f4aba5d358567ebefe768e8a50102cde11f35ef3497ddc2747c + languageName: node + linkType: hard + "bn.js@npm:^5.2.5": version: 5.2.5 resolution: "bn.js@npm:5.2.5" @@ -1954,9 +2267,9 @@ __metadata: languageName: node linkType: hard -"casper-js-sdk@npm:5.1.0": - version: 5.1.0 - resolution: "casper-js-sdk@npm:5.1.0" +"casper-js-sdk@npm:5.1.1": + version: 5.1.1 + resolution: "casper-js-sdk@npm:5.1.1" dependencies: "@ethersproject/bignumber": "npm:^5.8.0" "@ethersproject/bytes": "npm:^5.8.0" @@ -1972,7 +2285,7 @@ __metadata: humanize-duration: "npm:^3.34.0" ts-results: "npm:@casperlabs/ts-results@^3.3.5" typedjson: "npm:^1.8.0" - checksum: 10c0/5cc43571a2781fce82641f1362982a173a7cfb7950b456f08fb0ed27d3931657216e0f516e9046f0005330ca21d0b0d47b6cc6591027e3ca5ffe8a0fba0dbc61 + checksum: 10c0/3108d432a7259675cf22d6a3ad4277a3535e2cccf54b02f34438e494e3c8c9bc34b43e9ef62fa16713586b440f112a687b43376f786ef70f2085a3dd1c5745f3 languageName: node linkType: hard @@ -2106,6 +2419,47 @@ __metadata: languageName: node linkType: hard +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: 10c0/8c4121c243baf0678c65dcac29b201ff0067dfecf978de9d5c83b2ff127a8fdefd2bfd54577f5ad8c80ed7d2c8b489ae01c82023545d010c4ecb87683fb403dd + languageName: node + linkType: hard + +"cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 10c0/d74017b209440822f9e24d8782d6d2e808a8fdd58fa626a783337222fe1c87a518ba944d4c88499031b4786e68772c99dfae616638d71906fe9f203aeaf14411 + languageName: node + linkType: hard + +"cssstyle@npm:^2.3.0": + version: 2.3.0 + resolution: "cssstyle@npm:2.3.0" + dependencies: + cssom: "npm:~0.3.6" + checksum: 10c0/863400da2a458f73272b9a55ba7ff05de40d850f22eb4f37311abebd7eff801cf1cd2fb04c4c92b8c3daed83fe766e52e4112afb7bc88d86c63a9c2256a7d178 + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" + dependencies: + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 10c0/051c3aaaf3e961904f136aab095fcf6dff4db23a7fc759dd8ba7b3e6ba03fc07ef608086caad8ab910d864bd3b5e57d0d2f544725653d77c96a2c971567045f4 + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.2": version: 1.0.2 resolution: "data-view-buffer@npm:1.0.2" @@ -2170,7 +2524,7 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.6.0": +"decimal.js@npm:^10.4.2, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa @@ -2232,6 +2586,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -2255,6 +2616,22 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" + dependencies: + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/774277cd9d4df033f852196e3c0077a34dbd15a96baa4d166e0e47138a80f4c0bdf0d94e4703e6ff5883cec56bb821a6fff84402d8a498e31de7c87eb932a294 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -2287,6 +2664,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2472,6 +2856,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.0.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10c0/e1450a1f75f67d35c061bf0d60888b15f62ab63aef9df1901cffc81cffbbb9e8b3de237c5502cf8613a017c1df3a3003881307c78835a1ab54d8c8d2206e01d3 + languageName: node + linkType: hard + "eslint-config-prettier@npm:^8.5.0": version: 8.10.0 resolution: "eslint-config-prettier@npm:8.10.0" @@ -2718,7 +3120,7 @@ __metadata: languageName: node linkType: hard -"esprima@npm:^4.0.0": +"esprima@npm:^4.0.0, esprima@npm:^4.0.1": version: 4.0.1 resolution: "esprima@npm:4.0.1" bin: @@ -2767,6 +3169,13 @@ __metadata: languageName: node linkType: hard +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + "eventsource@npm:^2.0.2": version: 2.0.2 resolution: "eventsource@npm:2.0.2" @@ -3247,6 +3656,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" + dependencies: + whatwg-encoding: "npm:^2.0.0" + checksum: 10c0/b17b3b0fb5d061d8eb15121c3b0b536376c3e295ecaf09ba48dd69c6b6c957839db124fe1e2b3f11329753a4ee01aa7dedf63b7677999e86da17fbbdd82c5386 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -3254,6 +3672,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/32a05e413430b2c1e542e5c74b38a9f14865301dd69dff2e53ddb684989440e3d2ce0c4b64d25eb63cf6283e6265ff979a61cf93e3ca3d23047ddfdc8df34a32 + languageName: node + linkType: hard + "https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" @@ -3287,6 +3716,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:0.6.3": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + "ignore@npm:^5.0.5, ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -3536,6 +3974,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -3864,6 +4309,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-jsdom@npm:29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/139b94e2c8ec1bb5a46ce17df5211da65ce867354b3fd4e00fa6a0d1da95902df4cf7881273fc6ea937e5c325d39d6773f0d41b6c469363334de9d489d2c321f + languageName: node + linkType: hard + "jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" @@ -4196,6 +4662,45 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" + dependencies: + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/b109073bb826a966db7828f46cb1d7371abecd30f182b143c52be5fe1ed84513bbbe995eb3d157241681fcd18331381e61e3dc004d4949f3a63bca02f6214902 + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -4350,7 +4855,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -4377,6 +4882,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + "make-dir@npm:^4.0.0": version: 4.0.0 resolution: "make-dir@npm:4.0.0" @@ -4579,6 +5093,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.2": + version: 2.2.27 + resolution: "nwsapi@npm:2.2.27" + checksum: 10c0/14c1f6055dbe97b7564605ec7f39954f65ca47910345cf1e2aef7e6366276b9b12a0bad2d6090b418f78f9791a4f8d9ac098f94d85fd3acd7e3f491d75677476 + languageName: node + linkType: hard + "object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -4757,6 +5278,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0, parse5@npm:^7.1.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -4785,7 +5315,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -4861,6 +5391,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -4907,7 +5448,16 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"psl@npm:^1.1.33": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/d8d45a99e4ca62ca12ac3c373e63d80d2368d38892daa40cfddaa1eb908be98cd549ac059783ef3a56cfd96d57ae8e2fd9ae53d1378d90d42bc661ff924e102a + languageName: node + linkType: hard + +"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -4928,6 +5478,25 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + +"react-dom@npm:18.3.1": + version: 18.3.1 + resolution: "react-dom@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + scheduler: "npm:^0.23.2" + peerDependencies: + react: ^18.3.1 + checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -4935,6 +5504,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -4942,6 +5518,15 @@ __metadata: languageName: node linkType: hard +"react@npm:18.3.1": + version: 18.3.1 + resolution: "react@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -4979,6 +5564,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -5063,6 +5655,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:7.8.2, rxjs@npm:^7.8.2": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + "safe-array-concat@npm:^1.1.3": version: 1.1.3 resolution: "safe-array-concat@npm:1.1.3" @@ -5097,13 +5698,40 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:^2.1.0": +"safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + +"scheduler@npm:^0.23.2": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 + languageName: node + linkType: hard + +"semver@npm:7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e + languageName: node + linkType: hard + "semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -5263,7 +5891,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 @@ -5455,6 +6083,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "synckit@npm:^0.11.13": version: 0.11.13 resolution: "synckit@npm:0.11.13" @@ -5521,6 +6156,27 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.2": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/aca7ff96054f367d53d1e813e62ceb7dd2eda25d7752058a74d64b7266fd07be75908f3753a32ccf866a2f997604b414cfb1916d6e7f69bc64d9d9939b0d6c45 + languageName: node + linkType: hard + +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" + dependencies: + punycode: "npm:^2.1.1" + checksum: 10c0/cdc47cad3a9d0b6cb293e39ccb1066695ae6fdd39b9e4f351b010835a1f8b4f3a6dc3a55e896b421371187f22b48d7dac1b693de4f6551bdef7b6ab6735dfe3b + languageName: node + linkType: hard + "ts-api-utils@npm:^2.5.0": version: 2.5.0 resolution: "ts-api-utils@npm:2.5.0" @@ -5579,7 +6235,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.4.1": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -5740,6 +6396,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.3.0": version: 1.3.2 resolution: "update-browserslist-db@npm:1.3.2" @@ -5763,6 +6426,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "uuid@npm:^14.0.2": version: 14.0.2 resolution: "uuid@npm:14.0.2" @@ -5783,6 +6456,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" + dependencies: + xml-name-validator: "npm:^4.0.0" + checksum: 10c0/02cc66d6efc590bd630086cd88252444120f5feec5c4043932b0d0f74f8b060512f79dc77eb093a7ad04b4f02f39da79ce4af47ceb600f2bf9eacdc83204b1a8 + languageName: node + linkType: hard + "walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -5792,6 +6474,39 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/91b90a49f312dc751496fd23a7e68981e62f33afe938b97281ad766235c4872fc4e66319f925c5e9001502b3040dd25a33b02a9c693b73a4cbbfdc4ad10c3e3e + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" + dependencies: + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/f7ec264976d7c725e0696fcaf9ebe056e14422eacbf92fdbb4462034609cba7d0c85ffa1aab05e9309d42969bcf04632ba5ed3f3882c516d7b093053315bf4c1 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -5917,6 +6632,35 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.11.0": + version: 8.21.3 + resolution: "ws@npm:8.21.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7b28dc2863ea0e2cece68d142a3eee90361021b73f750431e6d8076bb7dede5fdfdb75b3d29534b62f411261147b76b1bc80fb5cc63ab0aabd467280e85b22e0 + languageName: node + linkType: hard + +"xml-name-validator@npm:^4.0.0": + version: 4.0.0 + resolution: "xml-name-validator@npm:4.0.0" + checksum: 10c0/c1bfa219d64e56fee265b2bd31b2fcecefc063ee802da1e73bad1f21d7afd89b943c9e2c97af2942f60b1ad46f915a4c81e00039c7d398b53cf410e29d3c30bd + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8"