Shared swap, transaction, signing and Ledger layers - #67
Draft
Comp0te wants to merge 71 commits into
Draft
Conversation
Declares react and @tanstack/react-query as optional peer dependencies for the upcoming src/react sub-tree, adds jsx support to tsconfig, keeps the react tree out of jest coverage, and pulls in big.js as a devDependency for decimal.js parity tests. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Entities, repository contracts, error types and per-network DEX constants for the trade API and on-chain DEX operations. SwapError carries the failed response envelope and status so consumers can read the trade API's own error codes after wrapping. casper-js-sdk is referenced by type only, keeping the domain barrel on the SDK-free startup path. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Ports cspr-trade's big.js amount math, slippage and validation helpers onto decimal.js with plain string amounts, plus swap rate/fee/route resolution and fiat display math. A local Decimal.clone keeps the global config untouched. Property tests assert parity against the original big.js formulas. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
SwapRepository covers the trade API (quotes, token listings, fiat rates, ownership, swap history) over the injected HTTP provider and joins the SDK-free setupDataRepositories path. DexContractRepository reads balances and allowances over RPC and builds unsigned approval, swap and wrap/unwrap transactions — as TransactionV1 or legacy Deploy — and joins setupSigningRepositories behind a dexConfig parameter. Signing stays with the consumer: the builders never sign or submit. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Optional React layer consumed by deep import (casper-wallet-core/src/react), never from the package root. Provides the ISigner contract that consumers implement against their own wallet integration, repository and settings providers, TanStack Query hooks with cache keys matching cspr-trade's, and the token, swap and wrap/unwrap orchestrators behind them. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Covers setup with dexConfig, mounting the providers, implementing an ISigner adapter, settings storage adapters, supplying the proxy WASM per platform, the SwapError shape, and the wrap/unwrap flow. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Drops src/domain/constants/dex.ts. The per-network trade API url and the trade and WCSPR contract package hashes move to casperNetwork.ts alongside the other network-keyed urls and package hashes; the fee, slippage, deadline and gas constants move to config.ts. CSPR_TOKEN is gone: symbol, name and decimals now come from CSPR_COIN and CSPR_DECIMALS, and the swap domain's synthetic native-token id becomes a single CSPR_NATIVE_TOKEN_ID, absorbing the copies of that literal in utils/swap.ts and DexTokenDto. utils/swap.ts also loses its private duplicates of the protocol fee, the slippage/deadline bounds and the payment-motes amounts. SLIPPAGE_STORAGE_KEY and DEADLINE_STORAGE_KEY are removed — naming storage keys is the host app's call, not this library's. ContractSettingsProvider now takes storageKeys next to storage, typed as a pair so neither is usable alone. This breaks consumers already persisting under the old keys; they own the migration. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The dex repository queried RPC directly for balances only because cspr.trade's API had no balance endpoints. The wallet API has them, so every balance and fiat rate the swap UI shows now comes from the same source that backs the wallet's own balance display, and no read path falls back to RPC. IDexContractRepository loses getTokenBalance and getCsprBalance, keeping the allowance reads, the latest block time and the transaction builders — allowance lives in a contract dictionary the indexer does not expose, so it stays on RPC. ISwapRepository drops four of its seven methods. getSwapsHistory had no consumers at all. getTokenFiatRate duplicated token_market_data that the token listing already returns, and its hook was exported but never called. getCsprFiatRate and getAccountTokenOwnership are replaced by TokensRepository.getCsprFiatCurrencyRate and getTokens, the latter gaining a contractPackageHashes filter so a single token's balance costs one narrowed request. ISwapHistoryEntry and the four shapes only it referenced go with them. The CSPR balance now reports liquidBalance rather than the purse total: staked and undelegating motes cannot be spent, and offering them as swappable would build transactions the chain rejects. Fiat is USD-only — SupportedFiatCurrencies already said so — so currencyId and currencyCode leave the React context in favour of USD_CURRENCY_ID and USD_CURRENCY_CODE. Consumers must add tokensRepository to the provider value, drop those two fields, and stop importing useFetchTokenFiatRates. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The React layer had no tests at all and was excluded from coverage outright, so the previous commit's change of backend was checked by the compiler and nothing else. Adds the missing harness and covers the four hooks it touched. renderHookWithProviders mounts a hook under the real providers with stub repositories and retries disabled. It lives outside the __test-utils__ barrel so the node-environment suites do not start pulling in React; the React suites opt into jsdom per file rather than switching the whole project over. Two config quirks worth knowing. testMatch now accepts .tsx. And dom-accessibility-api, reached through @testing-library/dom, has to be transformed: moduleFileExtensions resolves .ts ahead of .js, so Jest reaches that package's TypeScript sources instead of its build and fails to parse them. The assertions pin what the move actually changed — which repository each hook calls and with what, an unheld token reading as "0" rather than unknown, and the CSPR balance coming from liquidBalance rather than the staked-inclusive total. Coverage of src/react is 12.6% of lines: it now measures the gap instead of hiding it, and the hooks left untested are the ones this work did not touch. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The helpers added in cb1ff38 re-implemented formulas that already lived in `utils/common.ts`, and the two versions disagreed: `rawToFormatted` and `getDecimalTokenBalance` returned different strings for the same balance once it passed 20 significant digits, with both exported from the same barrel. Route the callers through the existing utils and fix those instead: - `getDecimalTokenBalance` and `getBlockchainAmount` absorb `rawToFormatted`, `formattedToRaw`, their `*Safe` variants and `divideCEP18Balance`. Both now scale through the Decimal constructor's exponent parsing rather than `div`/`mul`, so no digits are lost past the default precision, and both take an optional fallback in place of the deleted safe wrappers. - `getBlockchainAmount` truncates instead of rounding half-up, so it can no longer hand back more base units than the caller typed. - `formatFiatBalance` absorbs `formatSmallFiatAmount`, gaining `currencyCode` and `minFractionDigits`, and tests the actual amount against the one-cent floor so a half-cent stays `<$0.01` instead of rounding up to `$0.01`. - `formatTokenAmount` gives way to `formatTokenBalance`, so swap rates and fees use the display formatting the rest of the app already uses. - `isValidAmount` and `isAmountValid` become `isPositiveAmount` and `isAmountInputValid`; the unused helpers added alongside them are dropped. `utils/decimal.ts` holds the one high-precision clone the slippage math needs, replacing the copy that each of the two modules carried. 20 digits is too few there: a 21-digit balance times a slippage factor rounds the min-amount-out up, weakening the protection the caller asked for. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Fields an integrator holds must not expose the wire format's snake_case. Renames the five leaks that reached the public surface: - ISwapQuote: amount_in, amount_out, execution_price, mid_price, price_impact, recommended_slippage_bps, type_id - IAppMarketingEvent.image_url and INft.owner_reverse_lookup_mode, each the one field its DTO had skipped while mapping every sibling - IOnRampCurrencyItem.type_id - IUseFetchSwapQuoteParams.type_id, which the hook already converted internally on the way to IGetSwapQuoteParams.typeId onRamp needed more than a rename: IOnRampCurrencyItem was doubling as the payload description, imported by the data layer and passed through unmapped, so the field would have stayed snake_case at runtime. The wire shape moves to IOnRampCurrencyItemResponse and both DTOs map through it. Raw payload types keep snake_case — they mirror the API and no consumer holds them. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Move the raw indexer payload shapes out of `domain/swap` into `repositories/swap/types.ts`, where the sibling raw types for the same endpoints already live, and keep only the fields `DexTokenDto` actually reads. The dex-local market-data type is dropped in favour of the shared `ITokenMarketData`, which `deploys` and `tokens` already import. `DexTokenDto` now resolves its rate and volume through `getPreferredTokenMarketData` like every other DTO: the `token_market_data(1)` include filters by currency, not by dex, so the first array element is not necessarily the preferred one. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Untracked planning scratch docs were failing the format check. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
`useWrapTokens` no longer reads the WCSPR balance over RPC, and the `utils/casperSdk` barrel gained a second sdk-linking module in `./dex-contract`. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The swap hooks injected their repositories, network, active account and signer through two React contexts. Neither consuming app uses context for wallet state — both hold repositories as module singletons and read network and the active account from Redux — so a provider was a third pattern with no consumer. Every hook now declares the dependencies it needs on its single object parameter, typed as `Pick<ISwapDependencies, ...>`. The two orchestrators accept the union their children need and thread it down; return shapes are unchanged. `useFetchToken` and `useSwapRouteTokens` move from positional to object parameters for consistency. Slippage and deadline lose their state holder along with the provider and become required parameters. `clampSlippageValue`/`clampDeadlineValue` and the DEFAULT/MIN/MAX constants stay exported, so a consumer applies the same limits before persisting a value. Folded in: three query keys omitted `network` while their queryFn closed over it, so a network switch served the previous network's cache and never refetched — `latestBlock` worst of all, pinned for the session by `staleTime: Infinity`. All three are network-scoped now, covered by tests that switch networks against a shared QueryClient. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The mounting section becomes a dependency-object section, and the settings storage adapter becomes consumer-owned slippage and deadline with the clamp helpers the library exports. Adds a note that repositories must be stable references: the hooks put them in effect dependency arrays, so a repository rebuilt every render sends `useTokenBalances`'s CSPR refetch into a loop. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The route from the trade API was encoded verbatim into the signed payload while the UI rendered the locally-selected tokens. `amount_out_min` bounds how much the user receives but not which token it is, so a substituted terminal hop would pay them in something they never chose. `buildSwapTransaction` now pins both ends of the route to the selected pair. Alongside it, at the same chokepoint: slippage is rejected outside [0, MAX_SLIPPAGE] — 100 encodes `amount_out_min: 0`, which is what a basis-points/percent confusion on the quote's `recommendedSlippageBps` produces — and the deadline is bounded and now derived from chain time rather than the device clock, since the contract compares it against block time. `calculateMinAmountWithSlippage` and `calculateMaxAmountWithSlippage` fail closed instead of returning an inverted or unprotected bound. The approval grant is derived from the same `requiredAmount` the approval check is made against. It was derived from the user's whole balance, which nothing ever passed, so the default outcome was `approve(0)`: the check stayed true, the user paid 5 CSPR per attempt, and no retry could clear it. This also drops the standing allowance from 120% of the holding to 120% of `amount_in_max`, and makes `firstRawTokenBalance` unused — removed. `IDexConfig.getProxyWasm` is required, so a dexConfig without it fails at setup rather than at the Confirm button. Guide: the wrap example built the transaction from the user's balance instead of the typed amount; `deadline` was attributed to a hook that has no such parameter; the QueryClientProvider rule listed two directories, one of which needs it and one of which does not. Adds the missing useReviewSwap example and documents the two warning thresholds as consumer-owned. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…failures The branch adds two domain modules, two repositories and a React layer, renames three published domain interface fields to camelCase, and changes the results of two exported utils — all against a package.json still reading 1.4.0 and an empty [Unreleased]. Two consuming repos would have taken the upgrade with no signal at all. Populates the section and bumps to 2.0.0; the three renames and both util changes are intentional and stay as they are, what was missing was the note. The getBlockchainAmount and formatFiatBalance entries are written from probe output, not from the diff, since formatFiatBalance's new sub-cent threshold reaches deploy-history and CEP-18 rows well outside swap. getAllowance was the one DexContractRepository method bypassing _processError, returning '' for both "never approved" and "node unreachable" on a public interface typed Promise<string>. It now rejects, and '' means only the former; checkApprovalRequired's existing fail-safe already turns the rejection into "approval required". handleSwitchTokens guarded the re-derivation on `decimals` being truthy, so promoting a zero-decimals token kept the previous token's scale and locked the form into a false "exceeds balance". Uses the typeof check the file's three other sites use. README: the factory surface was stale in six places and the React entry point was undiscoverable from it. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…erting Each of these was confirmed by mutation before and after: the mutation named below left the suite green at 5ef1cde and fails now. The swap builder's inner args were checked by name only. The recipient of the swapped-for tokens, the on-chain expiry and the route could all be replaced with something else and all 39 dex tests stayed green. They are now asserted by value. keysToHex built its expectation by calling keysToHex, so dkLen: 16 or a swapped concatenation order moved both sides together. Replaced with fixed hex vectors for the serialized key bytes and the digest, cross-checked against a blake2b implementation outside this dependency tree. This does not confirm the deployed contracts key allowances this way — it confirms the derivation cannot drift unnoticed. useCsprFeeValidation inverts hasEnoughCSPRBalance and is the only guard stopping a user signing a swap they cannot pay gas for; dropping the `!` was invisible. The four orchestrators are the API the consuming apps render against, and nothing in this repo reads their return fields, so renaming one type-checked clean and shipped green. useSwapTransaction forwards slippage and deadline as two bare numbers. Transposing them type-checks and, until now, passed: the swap would carry an amount_out_min 20% below the quote and a 3-minute deadline. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
confirmSwap wrapped the approval check, the approval and the swap in one try ending in an
unbound `catch { setError('Transaction failed') }`. A user who cancelled in their wallet, a
user whose swap reverted after 30 CSPR, and a consumer who simply forgot to configure a
signer all saw the same sentence, and support could not tell them apart. Binds the error and
maps it with getTransactionErrorMessage, which this library ships for exactly this and which
the wrap flow already uses.
The step also stayed on 'signing' after a failure, and isProcessing derives from it, so the
modal sat processing forever with no path but closing it. It returns to 'confirm'.
ApprovalState.error and the swap's error were the same piece of state, so a swap that failed
after a successful approval rendered the approval step as failed. Each leg now carries its
own.
useTokenApprovalFlow's checkApprovalRequired caught everything and returned false —
"no approval needed", the unsafe direction, and the opposite of what the repository
deliberately does one layer down. A host app supplying its own IDexContractRepository, which
is what ISwapDependencies invites, would have had swaps submitted with no allowance.
The quote's block-time query was retry:false with staleTime:Infinity, and a missing block
time set the refetch interval to false, so one RPC failure at mount silently froze the
displayed quote for the session. It retries, falls back to BLOCK_INTERVAL_MS, and reports
the failure.
Two .catch() blocks on react-query refetch() were unreachable — QueryObserver catches its
own rejection unless throwOnError is set — so the comments described a failure mode that
cannot occur. Removed; the refetchCsprBalance catch beside them is live and stays.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…swap surface
Two rules, both scoped to src/react/** and src/data/repositories/dex/** rather than
repo-wide. Repo-wide the catch rule reports 25 sites, 23 of them pre-existing and outside
this feature, and type-aware linting surfaces a larger backlog still; absorbing either here
would turn a review pass into a refactor.
`catch {}` discards the error so nothing can log, classify or re-throw it — the shape behind
two of this branch's findings. no-empty resolves to severity 0 here and would not have
matched a non-empty `catch { return false; }` anyway, so this is no-restricted-syntax on
CatchClause[param=null]. The two in-scope sites are genuinely deliberate and now say so on
the line.
strict-boolean-expressions is configured with allowNullableString, since `!activePublicKey`
is the repo's idiom for "absent" and '' and null mean the same thing there. That leaves
exactly the class worth having: a nullable-number truthiness test on the block timestamp —
the same shape as the zero-decimals bug fixed earlier on this branch — a bare string used as
a filter predicate, `!ownershipData?.length`, two nullable-boolean isBlacklisted tests, and
three `tokens` guards in useTokenPreselection that were always true because the caller passes
`tokens ?? []`. Those last three are rewritten to say what they do; the loading distinction
they were meant to make no longer exists and needs its own change.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Types that admitted states the code never produces:
IBuiltDexTransaction had transaction and deploy as two independent optionals with the
"exactly one" rule living in a trailing comment, so the library's own recommended signer
adapter had to write `deploy ?? transaction!` and two casts to consume it. As a union it
narrows on `'transaction' in built`, and a future builder returning neither is a compile
error in the library rather than a crash in the consumer's signer.
useCsprFeeValidation encoded two exclusive modes as four independent optionals. Supplying
neither compiled and returned a validator that checked gas and nothing else, so a user
swapping their whole CSPR balance passed a check that never looked at the balance.
ApprovalState.transactionHash was public API that nothing ever assigned — no onSent was
passed at the checkAndApprove call site — so a consumer rendering an explorer link for the
approval step always read undefined.
Tests for branches that could not fail, each verified by the mutation named in its comment:
the contract-version selection behind every allowance read (the only fixture held one
version, so the comparison never ran); the quote's auto-refresh, which the one existing test
opted out of while every production caller opts in; the two-level unwrap that recovers the
trade API's error code; the deep-link path additionalContractPackageHashes exists for; and
useWrapTokens in both directions, where either of two plausible regressions locks users out
of unwrapping entirely.
The fiat-rate test named for "no account connected" passed the same arguments as its
neighbour, because the params have no account field at all. Renamed to what it checks and
strengthened to assert the call carries exactly { network }, which is what would break if
account-gating were added.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…stants Declare the public contract of the new shared transaction layer: ICasperSigner, the repository interface, param types, RPC options, and CasperTransactionsError with the mobile-compatible i18n message keys. Adds CASPER_MESSAGE_HEADER, CSPR_COIN_INDEX and the NFT standard map next to the SDK-linked NFT builders so the SDK-free import graph stays intact. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
createPrivateKeySigner turns the {publicKeyHex, secretKeyBase64} pair both vaults
already store into an ICasperSigner, with signature bytes matching the mobile and
extension paths. Adds the shared helpers those paths duplicated:
getPrivateKeyHexFromSecretKey, createCasperMessageBytes, isTransactionSignedBy
and isValidCasperPublicKey.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…acks
Four pure builders (CSPR, CEP-18, NFT, auction manager) returning the
{transaction, fallbackDeploy} pair both apps hand-roll today, plus
AuctionManagerEntryPointMap. The NFT fallback is built straight from
makeNftTransferDeploy instead of the '1.5.8' round-trip hack; a byte-equality
test pins the two against each other.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…d API version CasperTransactionsRepository owns the drift-corrected build timestamp (node time vs local-2s) and getNetworkApiVersion, so app sagas stop constructing RPC clients. Client construction is extracted into createCasperRpcClient, which centralises the platform-divergent referrer mechanics behind ICasperRpcOptions. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…send flows sendTokenTransfer/sendNftTransfer/sendDelegation build, sign through ICasperSigner and submit in one call (mobile parity), while sendSignedTransaction keeps the granular path the extension needs. Submission branches on the node API version: putTransaction on 2.x, the legacy deploy on 1.x. signTransaction refuses a transaction this key already approved. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
… repository The transactions repository can now sign and submit a built DEX artifact, keyed on which artifact the builder produced: a deploy goes out via putDeploy regardless of node version, a V1 transaction via putTransaction, and no fallback pairing is involved because the choice was already made at build time. DexContractRepository drops its hand-rolled client for createCasperRpcClient with injectable rpcOptions, so mobile can drive dex RPC over axios; the default now sets the proxy referrer. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Apps no longer implement a signing interface for swaps. The react ISigner becomes IDexTransactionSender, moves to domain/dex next to ITransactionCallbacks, and core builds it via createDexTransactionSender over an ICasperSigner: it submits through sendDexTransaction, resolves once the hash is back, and drives settlement and cancellation through the callbacks. Hook behaviour and the dependency field name are unchanged. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
setupSigningRepositories now constructs the transactions repository and threads an optional rpcOptions into it and into DexContractRepository, so mobile can select axios plus the literal Referer header while browsers keep the fetch defaults. Documents Phase 1 in the changelog, including the two consumer-visible deltas: the dex client's new default referrer and the IDexTransactionSender rename. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Comments carried internal decision ids (D4, D6/D14, D11, D12, D13) and WALLET-1421 ticket references, neither of which means anything to a consumer of this package. Several also narrated how the code came to be — what a re-export used to do, why a factory was split, why an assertion is written the way it is — rather than the constraint that still binds. Both are dropped, and the surrounding sentences are rewritten in present tense so they read as rules rather than as a record of past reviews. Comments that survive are shortened where the same fact fits in fewer lines; no behaviour changes. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Four control-flow gaps where an error path reported itself and then kept
going, or where a signer lost the artifact it needed:
- `sendDexTransaction`'s deploy branch now hands the signer
`{ fallbackDeploy }`, matching `_signAndSubmit`. Without it a Ledger on a
pre-v3 Casper app is told to update its app for every swap, wrap and
unwrap, while the transaction it was handed is exactly the legacy deploy
that app can sign. The V1 branch has no fallback to give:
`IBuiltDexTransaction` sets `transaction` or `deploy`, never both.
- `getAccountList`'s `DeviceLocked` and `CasperAppNotLoaded` branches are
terminal via `#processError`, like the `else` beside them. They used to
fall through and publish and cache whatever key bytes the device returned
on the error path.
- `connect` returns after rejecting on an unavailable transport instead of
continuing into the retry loop, whose events used to reach a running
flow's `events$` long after the caller was told the connection failed.
- `useReviewWrap` returns `ledgerEvent`, which the wrap flow merges and the
wrap reducer stores but nothing read.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
`IFlowHandle.done` promises never to reject and the plan's matrix pins it, but that held only by each generator's own discipline. `runSwap` computed `calculateMaxAmountWithSlippage` and `calculateApprovalAmount` before its first yield and outside every try, so an unclamped slippage — which the caller is documented to clamp and no in-library call site does — errored `events$`, rejected `done`, and left the modal on `confirm` with no message. - `createFlowHandle` takes `toFailureEvent` and maps a throw out of the generator to that terminal event, so the contract is structural. - `runSwap`'s pre-flight arithmetic yields `failed` on the approval leg. - Both review hooks subscribe with an `error` handler; a merged `ledgerEvents$` can still error the stream, and rxjs would otherwise rethrow it out of band. - `observeTransaction` completes after the outcome when a signal is supplied. `abortAsError$` never completes on its own, so the merge used to hang and the abort listener was never removed. - `isLedgerSignatureCancelled` is exported and is the default `isCancellationError` for both flows, so an on-device rejection is a cancellation rather than a failure. `getTransactionErrorMessage` renders a `LedgerError` as its device status: the error's `message` is the JSON of the whole event, carrying the public key and transaction hash. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Three defects with one shape: the hooks let a second flow start, or refused to let the first one retry, or started a flow from four values nothing tied together. - The re-entrancy guard now tracks liveness rather than identity. It is released by `handle.done` — the one signal that fires on every terminal path, including one reached while the surface was closed. `useReviewWrap` never released it at all, so `confirmWrap` was dead for the life of the mounted hook after any terminal event; `useReviewSwap` released it in `resetForm` with no liveness check, so a consumer wiring `resetForm` to the modal's close handler could start a second approval and a second swap against the same balance. `resetForm` refuses while a flow is live. - `ISwapFlowRunner`/`IWrapFlowRunner` expose the account they are bound to, and both hooks refuse to start when it disagrees with `activePublicKey`, surfacing a `FlowError` through the existing error state. The runner's key is what the swap is built from, paid by, signed by and delivered to. - `useReviewSwap` takes one `trade: ISwapQuotedTrade | null` instead of four independent params, and `useSwapTokens` returns `quotedTrade` built off a single `quoteData.data`. Pairing a fresh input amount with a previous quote's output bound is an unprotected fill; the form's own amounts lag the quote by the input debounce and must not feed a build. The hook test doubles now use a `ReplaySubject` and a resolvable `done`, so replay-on-reopen and retry-after-failure are executed rather than implied. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Four ways a missing or unreadable value was indistinguishable from a real one: - `getDictionaryValue` returned `null` on every failure, so an RPC timeout, a 500 and a genuinely-absent allowance all reached `checkApprovalRequired` as `''` — which compares as "approval required" and returns *normally*, bypassing the fail-safe catch written for exactly this. It now returns `null` only for the node's own "not in state" codes and rethrows the rest, and the fail-safe logs. - `DexContractRepository` refuses to build against an empty contract package hash. The shipped defaults are `''` for devnet and integration, and an empty hash was built into a zero-length byte array, signed, submitted and reverted on chain. - The wrapped-CSPR hash was two independently-defaulting knobs — `ISetupDataRepositoriesParams` for `SwapRepository`'s synthetic native-CSPR token and `IDexConfig` for route validation. Setting only one rejected every native-CSPR swap as an invalid route. `IDexConfig` loses the field; `setupRepositories` resolves one value and feeds both halves. - `getDateForTransaction` binds and logs the node-time read it fell back from. The device clock is a deliberate fallback, but a skewed one has the node reject transfers as future-dated with nothing recording why. `setup.test.ts` and `setup.integration.test.ts` now assert the factory's whole returned surface, so dropping a repository from it fails a test and `tsc` rather than nothing at all. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
`formatFiatBalance` gained a `minFractionDigits` option with no stated relation to `decimals`, and both went straight to `Intl.NumberFormat`, which throws a `RangeError` when the minimum exceeds the maximum — out of a root-exported helper, during render. The minimum is clamped to `decimals`. The sub-cent label was also computed through the same `decimals`, so at 0 or 1 it rendered `<$0`, reading as "less than zero dollars" while the docstring above it still promised `<$0.01`. It formats with at least two places now, so the docstring holds. Also records `getDecimalTokenBalance`'s exactness change in the changelog, which enumerated the branch's other two exported-util behaviour changes and omitted this one. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Three places where the library used a value nobody could check. - `IDexConfig.expectedProxyWasmSha256` (optional). The proxy WASM executes 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" — so nothing downstream can tell a correct `proxy_caller.wasm` from a substituted one. Verified once per repository and cached. Not pinned in core: the binary is a per-deployment artifact of the DEX contracts, so a pin here would couple every proxy redeploy to a library release. - `buildRevokeApprovalTransaction`. A swap's approval is bounded, but the unspent remainder stays granted afterwards — including after one that reverted — to a contract *package* whose owner can upgrade what sits behind it. Nothing revokes automatically: that is a third signature, confirmation and payment, and it cannot run on the paths where signing failed. This makes it something a surface can offer. - `createPrivateKeySigner` verifies that the supplied `publicKeyHex` belongs to the secret key before signing. The algorithm was taken from the public key, so a mismatched pair signed under the wrong curve and only the node rejected it, after the payment was committed. The key is derived once and reused rather than rebuilt per signature. `SwapRepository` also gets its own `HttpDataProvider`. `setAuthHeader` writes `Authorization` as a default on the whole apisauce instance, so the wallet-API credential was going to `api.cspr.trade` too — a host it is not scoped to. If the trade API ever needs a credential it gets its own. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
`ITransactionOutcome` was one interface with a `'success' | 'failure'` status, so `swap:confirmed` — the event whose whole meaning is "this landed" — could carry a reverted outcome, and `swapFlowReducer` renders the success screen without inspecting it. The guarantee rested entirely on the producer's check. It is now discriminated on `status`, and both confirmed events are typed against the success arm. `ISwapFlowResult` and `IWrapFlowResult` were a status plus four independent optionals, so "failed with no error" and "success with a reverted outcome" were the same type as a real result. Both are unions keyed on `status` now. `outcome` stays optional on the success arm: with `awaitSettlement: false` the flow completes at submission, and that distinction is the point — read `outcome`, not `status`, to tell submitted from settled. `swapReducer.test.ts` gains three `@ts-expect-error` assertions covering exactly those states. `tsc` runs the test tree in `yarn code:check`, so widening any of the unions back fails the build. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The integration guide is what the extension and mobile teams will copy, so
every one of these was a wrong instruction rather than a stale note:
- The swap wiring did not compile: it spread `tokenAmounts.first`
(`{formatted, raw}`) into a slot needing `{amountFormatted, amountRaw}`,
and the natural hand-fix submits a swap for 10^-9 of the intended amount.
It now passes `quotedTrade` whole, and says why the four fields must come
from one quote.
- "Both runners are typically built once" was the opposite of the
requirement: a runner is bound to one account, so the guide now shows
memoizing on `activePublicKey` and explains what the key decides.
- The stable-reference paragraph blamed an unstable runner for breaking a
duplicate-submission guard it cannot reach — that guard is a `useRef`. The
real consequence is `getActive` losing every in-flight flow.
- The proxy-WASM recipe fetched the bytes at runtime, widening the exposure
from the app's asset pipeline to whatever that URL resolves to. Bundled
asset plus `expectedProxyWasmSha256` now.
- `isLedgerSignatureCancelled` was referenced in a snippet as a symbol the
package did not export. It exports it, and it is the default.
- New sections on retrying (and why `resetForm` is not part of it) and on
the allowance a swap leaves standing.
README's entry-point table understated the signing factory's exports (three
of five) and the repository count (twelve, actually fourteen), and named
three SDK-linking repositories where all five link it. The `casperSdk`
barrel docstring named two SDK-linked modules where there are five, and its
deep-import list omitted `tx-builders` and `validation`.
`SDK_FREE_ENTRY_POINTS` gains `src/react/index.ts`, so the README's claim
that the guards cover that row is now enforced rather than asserted. It is
SDK-free today; this stops a hook that value-imports a repository
implementation from silently adding ~900 KB to every consumer of that path.
The changelog records this branch's API as it now stands, including the two
breaking shape changes (`useReviewSwap`'s `trade`, `IDexConfig` losing
`wrappedCsprContractPackageHash`).
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Three assertions that could not fail, confirmed by mutating the code they stand over and watching the suite stay green: - The approval-grant test read `requiredAmount` out of one mock and `amount` out of another and asserted only `amount >= requiredAmount`. Both operands came from the implementation, so it held for any consistent pair — dropping the slippage widening from `requiredAmount` left it green. It asserts the two expressions the frozen matrix names now, via the real helpers. - `isDeploy` was never observed. Every flow fixture set `transaction`, so it was `false` in every test that existed and inverting the predicate in both flow files changed nothing. Both suites gain a legacy-Deploy fixture and assert the value handed to the settlement watch. A wrong one polls an RPC method the node will never answer for that hash, and `NoSuchTransaction` reads as still-pending — so a landed swap is reported as failed after the full 30-minute timeout. - The wrap revert test supplied `errorMessage: 'User error: 3'` and never looked at it, while the swap counterpart did. It asserts the failed event carries it, so the node's reason cannot regress to a generic string. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The comments added across this branch explained the diff rather than the code: the bug each guard prevents, the alternative that was rejected, the same constraint restated in four files. Trim them to the contract a caller has to know, drop the ones on private members whose public counterpart already carries the warning, and give the CHANGELOG entries the same treatment. No behaviour change. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Consuming apps pin the same version: a mismatch duplicates the SDK in the bundle and puts transaction bytes on two different builders. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…traction Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Comp0te
force-pushed
the
WALLET-1310-swap-extraction
branch
from
September 2, 2026 20:51
2991803 to
db2b844
Compare
…egex `^\d*\.?\d*$` lets both \d* compete for the same digits when no dot is present, so a long run of digits followed by a non-digit backtracks quadratically: 50k characters blocked the event loop for ~3.6s. `^\d*(\.\d*)?$` accepts exactly the same strings with no ambiguity. Also drops a redundant tokenOutHash check in useTokenPreselection that the preceding branch already guarantees. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…traction Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Set `maxWorkers` to 50% to prevent oversubscribing the machine and increased `testTimeout` to 15s for improved handling under heavy load.
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…rror Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
isOnRampError compared name against 'OnrampRepositoryError' while the class sets 'OnRampRepositoryError', so the guard never matched its own class and the onRamp repository double-wrapped errors it should have rethrown. The guard now matches; the class name is unchanged. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
…tail Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
yarn's 60s default socket timeout killed 'yarn npm audit' mid-request; the bulk-advisories call for this tree measures ~2m20s. Raise httpTimeout so the gate reports advisories instead of a transport error. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
A repository can decide per call path whether a failure is worth reporting — the same vault error is noise on one path and a real fault on another — which a class-field override cannot express. The argument is only a default: wrapping a domain error still inherits that error's flag, so a subclass cannot make an already-silenced failure noisy again. Existing subclasses pass three arguments and are unaffected. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
The 1.x deploy-wrapped branch was verified for the CSPR builder only, so a regression in the CEP-18, NFT or auction legacy path would have gone unnoticed; the new tests compare against independently built SDK deploys rather than against the fallback, which a pass-through regression would have satisfied. Also covers the per-method error tags on sendNftTransfer, sendDelegation, signTransaction and signMessage, Authorization merging under the default referrer mode, and the browser-safe RPC defaults reached through setupRepositories(). Every new assertion was run against a mutated implementation to confirm it can fail. Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Moves the swap/DEX feature and the transaction, signing and Ledger layers out of the two wallet apps and into this library, so mobile and the browser extension run one implementation instead of two near-identical ones.
Four layers land here, each usable on its own:
domain/swapanddomain/dex, the trade API and contract repositories (allowance reads; approval, swap, wrap and unwrap builders), and asrc/reacthook layer for the trade form, review modal and wrap flow.reactand@tanstack/react-queryare optional peers; a consumer that never importscasper-wallet-core/src/reactneeds neither.ICasperSignerplusCasperTransactionsRepository, which owns node-RPC client construction, node-time drift correction and API-version detection, and submits aputTransactionon 2.x nodes or a legacyputDeployon 1.x. It exposes a composed path (sendTokenTransfer/sendNftTransfer/sendDelegation, mobile's shape) and a granular one (signTransaction/sendSignedTransaction, for the extension's two-window Ledger UX). The purebuild*Transactionshelpers return the{transaction, fallbackDeploy}pair both apps hand-roll today.CasperLedgerServicereplacing the ~700-line device class in each app. Transports, availability checks, pairing-invalidation classification and the Casper app object are all injected, so the same service drives Web HID/USB and React Native BLE.@zondax/ledger-casperand@ledgerhq/hw-transportare optional peers imported nowhere insrc/; the shapes the service needs are declared structurally instead.createSwapFlowRunner/createWrapFlowRunnerbuild the approve → settle → swap → settle sequence as an async generator lifted to a hot, replayed observable. Unsubscribing never cancels a running flow, onlyhandle.cancel()does, so a closed UI surface cannot abandon or duplicate a submitted transaction;runner.getActive(id)lets a remounted surface reattach. Settlement is core-owned through the newITransactionStatusRepository.Breaking changes and the full surface are in
CHANGELOG.mdunder Unreleased; the consumer-facing guide isdocs/swap-react-integration.md.Motivation
Both wallets were carrying their own copy of transaction building, signing, submission and the Ledger device layer — roughly 95% identical, drifting independently, and each the place a money-critical bug had to be fixed twice. Extraction gives one implementation with one test suite, and it is what makes the swap feature affordable in both apps rather than built twice.
The extracted code was moved line-for-line so submitted bytes stay identical to what the apps produce today. The adoption work is planned separately in each app repo.
Related issues
Refs WALLET-1310
Notes for reviewers
Worth the attention:
createPrivateKeySignerchecks the key pair before signing (the curve comes from the public key, so a mismatched pair used to sign under the wrong curve and be rejected only by the node, after payment); the swap quote's four fields travel as onetradebundle so an input amount cannot be paired with a previous quote's output bound;IDexConfig.expectedProxyWasmSha256verifies the proxy WASM, which runs as session code against the caller's main purse while the UI and the Ledger prompt show only "ModuleBytes".ITransactionOutcome,ISwapFlowResultandIWrapFlowResultare discriminated unions — a'failed'result must carry its error, a'success'one cannot, and a flow confirms only on a successful outcome. A timeout is deliberately not an outcome: "we stopped waiting" is not "the chain rejected it".casper-js-sdkstays out of the SDK-free half.src/setupDataanddomain/must not link the ~900 KB UMD bundle;src/sdk-free-modules.test.tsis the static gate, and it counts type-only imports because the package ships raw TypeScript.Consumers pin
casper-js-sdkto exactly the version this package pins (5.1.1) — a mismatch duplicates the SDK in the bundle and puts transactions on two different builders.