Skip to content

Shared swap, transaction, signing and Ledger layers - #67

Draft
Comp0te wants to merge 71 commits into
masterfrom
WALLET-1310-swap-extraction
Draft

Shared swap, transaction, signing and Ledger layers#67
Comp0te wants to merge 71 commits into
masterfrom
WALLET-1310-swap-extraction

Conversation

@Comp0te

@Comp0te Comp0te commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Swap / DEXdomain/swap and domain/dex, the trade API and contract repositories (allowance reads; approval, swap, wrap and unwrap builders), and a src/react hook layer for the trade form, review modal and wrap flow. react and @tanstack/react-query are optional peers; a consumer that never imports casper-wallet-core/src/react needs neither.
  • Transactions and signingICasperSigner plus CasperTransactionsRepository, which owns node-RPC client construction, node-time drift correction and API-version detection, and submits a putTransaction on 2.x nodes or a legacy putDeploy on 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 pure build*Transactions helpers return the {transaction, fallbackDeploy} pair both apps hand-roll today.
  • Ledger — one CasperLedgerService replacing 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-casper and @ledgerhq/hw-transport are optional peers imported nowhere in src/; the shapes the service needs are declared structurally instead.
  • Flow layer — framework-neutral createSwapFlowRunner / createWrapFlowRunner build the approve → settle → swap → settle sequence as an async generator lifted to a hot, replayed observable. Unsubscribing never cancels a running flow, only handle.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 new ITransactionStatusRepository.

Breaking changes and the full surface are in CHANGELOG.md under Unreleased; the consumer-facing guide is docs/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:

  • Money-critical inputs are now verified rather than trusted. createPrivateKeySigner checks 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 one trade bundle so an input amount cannot be paired with a previous quote's output bound; IDexConfig.expectedProxyWasmSha256 verifies the proxy WASM, which runs as session code against the caller's main purse while the UI and the Ledger prompt show only "ModuleBytes".
  • Illegal states are unrepresentable where it counts. ITransactionOutcome, ISwapFlowResult and IWrapFlowResult are 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-sdk stays out of the SDK-free half. src/setupData and domain/ must not link the ~900 KB UMD bundle; src/sdk-free-modules.test.ts is the static gate, and it counts type-only imports because the package ships raw TypeScript.
  • Node-facing failure paths. The settlement watch tolerates a run of failed lookups before calling the node unreachable, and an unreadable allowance reads as "approval required", never "already approved".

Consumers pin casper-js-sdk to exactly the version this package pins (5.1.1) — a mismatch duplicates the SDK in the bundle and puts transactions on two different builders.


  • Commits are signed off (DCO)
  • Tests added or updated where applicable

Comp0te added 30 commits August 29, 2026 10:58
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>
Comp0te added 13 commits August 31, 2026 01:13
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>
@Comp0te Comp0te self-assigned this Sep 2, 2026
@Comp0te Comp0te added the enhancement New feature or request label Sep 2, 2026
Comment thread src/utils/amounts.ts Fixed
Comment thread src/react/hooks/token/useTokenPreselection.ts Fixed
…traction

Signed-off-by: Dmytro Vynnyk <simbiatoff@gmail.com>
@Comp0te
Comp0te force-pushed the WALLET-1310-swap-extraction branch from 2991803 to db2b844 Compare September 2, 2026 20:51
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants