Skip to content

Releases: Noirwire/shared-noirwire

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 07 Oct 07:10

A private move runs between any two of the person's own places: main wallet to portfolio as before, and now portfolio to main wallet and portfolio to portfolio. The portfolio signs for itself and needs no SOL, the same as the main wallet.

Breaking

For both apps:

  • Move: fundPrivately(deps, { from, to, amount, symbol }) replaces portfolioId with the two ends, each a portfolio's id or FUNDING. The move as it was is { from: FUNDING, to: portfolioId }. Its refresh needs portfolioAsset beside funding. A move to where it left, or to or from an archived portfolio, is refused.
  • Move, the arrival: awaitPrivateArrival(deps, { from, to, symbol, balanceBefore, amount }) takes the same two ends. An arrival in the main wallet is recorded there.
  • Move, the screens: fundingAmountView, fundingReviewView and fundingOutcomeView take an optional sourceLabel, the portfolio the money leaves; absent means the main wallet. portfolioLabel is where it arrives: pass fundingCopy.wallet.inSentence when that is the main wallet. fundingDraft's fundingBalance is what the place the money leaves holds.
  • Pending words: PendingWords.privateTransfer gets a null label for a move into the main wallet. pendingWords already answers it.

Added

  • fundingAmountView answers max, the most that can move once both fees are paid on top, and nothingToMove for a portfolio that holds none of the asset. fundingDraft answers the same max.
  • fundingCopy.from, to and swap for the two ends of a move.
  • A move out of a portfolio is listed there as "Moved to Main wallet" or "Moved to" the portfolio's name, once its transaction has landed.

Changed

  • The move's title is "Move money", and its wording no longer assumes the main wallet is where the money leaves.

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 14:32

A move to a portfolio is called arrived only once all of it is there. The settlement service delivers a private transfer in three parts, seconds apart, and the arrival was called on the first rise in the balance: a move of 10 USDC was reported, and recorded in Activity, as 3.33 USDC, while the other two parts landed a few seconds later.

Breaking

For both apps:

  • Move to portfolio: awaitPrivateArrival(deps, { portfolioId, symbol, balanceBefore, amount }) takes the amount that was moved, the same number passed to fundPrivately. It resolves once the balance has risen by all of it. When the window ends with only part of it there it resolves to the balance as it stands, and null still means nothing arrived.
  • Move to portfolio, the wait: fundingOutcomeView takes a new outcome, "onItsWay". Show it as soon as fundPrivately answers submitted, in place of the progress stages: it names the amount that was moved and can be closed. Keep awaiting the arrival behind it, and change to "done" when it resolves, or "pending" on null.
  • Routes: fundingSendParams and readSendSource are gone: neither app called them. A send from the main wallet passes FUNDING as its source.

Changed

  • The private move's footer on both platforms ends "Private moves are powered by MagicBlock."

Fixed

  • A read of the main wallet's balances that was on its way while an action stored a newer balance no longer writes the older number back over it.

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 12:59

The funding wallet is now called the main wallet, and it is a wallet in its own right. It is the person's own public wallet: money arrives there, it has a page of its own with one balance, its USDC and SOL, its activity and four actions, and it can send. Money for one of the person's own portfolios still only goes through Move to portfolio, which keeps the two apart in public. In code it is still funding everywhere: no type, field, function, route parameter or stored key was renamed.

Breaking

For both apps:

  • The name: every string that said "funding wallet" says "main wallet". Update any app test that asserts the old wording, and any string the app wrote itself. The table under Changed lists the lines that changed beyond the name.
  • Activity kinds: ActivityKind has a new "deposit" (money that arrived in the main wallet). Add it to any record or switch keyed on the kind. An entry's portfolioId can be FUNDING: activityRow and activityDetailView already name it "Main wallet" (portfolioFallback on the detail, with portfolio null), so draw the fallback where there is no portfolio to open.
  • The main wallet page: fundingWalletView(reads, wallet, updatedAt, freshness) replaces fundingWalletView({ balance, readFailed }). Draw total (null: draw nothing), assets under it, empty when not null, the four actions move, send, receive, addMoney (each { label, target, disabled, reason }), and activity. loading, unavailable and stale are drawn as on Home. Gone: waiting, balance, balanceLabel, lead, readFailed, move.quiet, and mobileFundingCopy.page.
  • Reading it: open the page, and the Move sheet, with money().refresh.fundingBalances(), not refresh.funding(address, "USDC"). Only the full read writes "Money arrived" into Activity; the single-asset read is the one an action makes after it lands and never does.
  • Home: draw view.fundingWallet (label, value, target: { to: "fundingWallet" }) as a row that opens the page. HomeTarget has the new member. The "is in your main wallet" notice and its action are as they were.
  • Send: open the sheet for the main wallet with fundingSendParams() and read its source with readSendSource (readPortfolioParam still answers null for it). Take sendSourceView(reads, wallet, sourceId, platform) for the title, the name, the own address, the assets with what is held, and empty. Pass its funding to sendFormView, fromFunding: true to sendReviewView, its name to sendResultView, and the same id as portfolioId to reviewSend and send. Reserve and read pending under FUNDING.
  • Send, refusals: reviewSend(...).recipient can be "ownPortfolio": show sendRecipientRefusal(review) on the form, as for a recipient that cannot receive. When the review's needsCash is shown for the main wallet its action is "Add money": open the add-money sheet, not Move.
  • Refusal reasons: RefusalReason has "moreThanFunding" and "ownPortfolioFromFunding"; refusalMessage words both.

For the web app:

  • SendDialog and walletActions.send / reviewSend are the call sites above; build the main wallet's page and its Home row.

For the mobile app:

  • FundingWalletScreen (the new view, and fundingBalances() on opening), useFundFlow (its opening read), useSendFlow and SendScreen (the source), usePendingBlock(FUNDING) and HomeScreen (the row) are the call sites above.
  • useSendFlow keeps unsendable as Unsendable | null: handle "ownPortfolio" before setting it.

Added

  • Money that arrives in the main wallet from outside is written into Activity as "Money arrived", by the difference a balance refresh finds, valued at the current price. Nothing is written while any action is unsettled, from an action's own read, or from the first read of a restored wallet.
  • send and reviewSend take FUNDING as the source: the main wallet sends its USDC and its SOL with its own key, pays the network from its own SOL when it holds enough, and is priced with the relayer when it does not.
  • fundingWalletView, fundingWalletValue, fundingActivity, sendSourceView, fundingSendParams, readSendSource, HomeView.fundingWallet, and the types FundingWalletView, FundingWalletAction, FundingWalletTarget, SendSourceView.
  • fundingCopy.wallet, sendCopy.mainWallet, networkCostCopy.mainWallet, errorsCopy.send.moreThanMainWallet, errorsCopy.send.useMove, activityCopy.movedToPortfolio.

Fixed

  • An action reserved for the main wallet that landed without this device seeing it wrote no activity entry. A send from the main wallet that lands unseen is now recorded once the chain shows it.

Changed

Where Was Is
every string "funding wallet", "Funding wallet" "main wallet", "Main wallet"
portfolioCopy.addMoney.arrived "{amount} USDC has arrived in your funding wallet. Move it to a portfolio before buying." "{amount} USDC is in your main wallet. Move it to a portfolio to invest."
settingsCopy.funding.lead "Money sent here must be moved into a portfolio before you can invest." "Your money arrives here. Move it to a portfolio to invest."
fundingCopy.emptyBefore "Your {asset} funding balance is empty. " "Your main wallet holds no {asset}. "
pendingActionCopy.subject.funding, .balance.funding "... from the funding wallet", "the funding wallet's balance" "... from your main wallet", "your main wallet's balance"
onboarding reunitedIntro, importedIntro "Opened the funding wallet at ...", "The funding wallet is at ..." "Opened your main wallet at ...", "Your main wallet is at ..."
classifyRecipient own label "Funding wallet" "Main wallet"
mobileFundingCopy.page the old page's words removed; the page reads fundingCopy.wallet

A record written by this version holds entries an earlier version does not know (deposit, and an entry of the main wallet), and an earlier version refuses to open it. Do not run an older build against a wallet this one has written to.

v0.6.1

Choose a tag to compare

@github-actions github-actions released this 06 Oct 11:53

Home's total value is everything the person has in the wallet: every active portfolio, what is in Earn, and now what waits in the funding wallet too (its USDC, its SOL and any other token, each at its own price). Money that has arrived and not yet been moved into a portfolio no longer leaves the total at zero. "Ready to invest" is unchanged: it counts cash inside portfolios only. A total that counts SOL or a tracker in the funding wallet needs a live price for it, by the same rule as a portfolio's holdings.

Nothing for the apps to change: homeView and portfolioOverview keep their shapes.

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 19:24

An unknown balance is never shown as a zero. A wallet unlocked while NoirWire could not be reached showed "$0.00" for its total, its cash and every portfolio, beside a notice that the figures "may be out of date": a person reads that as a real zero balance. Until balances have loaded once, every figure worked out from them is now null, the types say so, and the screen carries one line with a retry. Also here: Costs states prices only, the chart hint follows the pointer and not the platform, Home says when Earn does not run, and a testing note for the header, Welcome and About.

Breaking

The types changed on purpose, so an app cannot print a zero by accident: a figure from balances is Figure (string | null), and a component draws nothing for null. Each app keeps one ReadFreshness for its balance read already (0.5.3, for homeView); pass that same value everywhere below.

For both apps:

  • Home: view.total.value and view.cash.value are string | null, and so are value and line on each row of view.portfolios and view.archived.rows. Draw nothing for null (the waiting placeholder while view.loading), never "$0.00". New view.unavailable: { text, retry } | null: when it is not null, show text once, with a button labelled retry that runs the balance refresh, in place of the stale notice (view.stale is null then). view.empty is false and view.waiting is null until balances load, so the empty wallet's "Add money" lead and the "has arrived" notice are not drawn over an unread wallet.
  • Home, Earn: view.earning is renamed view.earn. It is null where Earn does not run (the app passes undefined as the Earn total off the main network, as now). When view.earn is null, hide the Home Earn tile and the "could be earning" card (portfolioCopy.earnPromo); do not decide that from the network in the app.
  • Portfolio list: portfolioRowView(reads, portfolio, updatedAt, balances) takes the balance read as a fourth argument. value and line are string | null.
  • Portfolio and pie pages: portfolioView(reads, wallet, id, updatedAt, balances, inEarn?): balances is the fifth argument and inEarn moves to sixth. value and cashLine are string | null; a pie's mix.slices[].trailing is string | null. New view.loading and view.unavailable (draw as on Home). Until balances load, primary, rebalance, holdings and empty are null, and every button in quiet but Receive has disabled: true and a reason: show the reason where the screen shows sendReason.
  • Tracker page: TrackerState.freshness takes balances as well (a visitor passes NEVER_READ). holding.quantity and .value were already nullable; until balances load they are null, holding.rows is empty and holding.none is null, so draw neither a quantity nor "You do not own this tracker yet". New view.unavailable. Buy is disabled and Sell is not offered until balances load.
  • Send: SendFormState takes balances. amountLine and available.value are string | null. balanceUnavailable now also carries the reason while balances have never loaded: keep drawing it where it is drawn.
  • Buy and sell: TradeFormState takes balances; available is string | null, and balanceUnavailable carries the reason. portfolioChoices(reads, wallet, side, symbol, balances) and noMoneyView(reads, wallet, portfolioId, balances) take it last; a choice's caption is string | null, and noMoneyView answers null until balances load.
  • Invest in a pie: pieInvestView takes balances; available is string | null, and new balanceUnavailable is the reason to show under the amount.
  • Move to portfolio: FundingAmountState takes a required readFailed: boolean (whether the funding wallet's read on opening failed) beside fundingBalance. New view.unavailable: show its line and retry when not null. The web's lead no longer says "Available 0.00 USDC" before the read: it names no amount. choosePortfolioView takes balances, and each row's cash is string | null.
  • Earn: earnScreenView, earnPortfolioView and earnSheetView take balances. A row's cash (and cash, cashAvailable on earnPortfolioView) is string | null; the sheet's available is string | null and it has a new balanceUnavailable. The screen has new loading and unavailable; until balances load, deposit and withdraw are disabled, deposit.reason is the unavailable line and no row opens anything. The Earn total was already "Unavailable", never a zero, until every position is read, and is unchanged. earnChoiceView and earnAmountView are unchanged: they are reached only through the actions the screen now holds back.
  • Chart hint: marketsCopy.detail.chartHint is now { touch, mouse } and mobileMarketsCopy.detail.chartHint is gone. Draw chartHint(pointer) from @noirwire/shared/presentation.
  • Testing note: draw betaView().tag ("Beta") as a small label beside the NoirWire mark in the app header and on Welcome. Draw aboutView().beta ("NoirWire is in testing. Start with small amounts.") as a line on Settings' About page. Both are shown on every network, the main network included: they are about the product, not the network. networkLabel() and the test-network banner keep their rule.

For the web app:

  • app/portfolio/page.tsx (homeView, portfolioView), PortfolioDetail, TradeDialog (tradeFormView, noMoneyView), SendDialog, FundPrivatelyFlow, PieOrderDialog, app/earn/page.tsx and app/markets/[symbol]/page.tsx are the call sites above.
  • DesktopHome draws the "could be earning" card from portfolioCopy.earnPromo: draw it only when home.earn is not null.
  • app/markets/[symbol]/page.tsx draws copy.chartHint: draw chartHint(pointer), where pointer is "touch" when the device reports a coarse pointer (matchMedia("(pointer: coarse)")) and "mouse" otherwise.
  • The Risks row in Settings reads settingsCopy.risks.description, which changed: nothing to do.

For the mobile app:

  • HomeScreen (view.earning becomes view.earn), PortfolioScreen, TrackerScreen, TradeSheet (portfolioChoices, tradeFormView, noMoneyView), SendScreen, FundScreen (fundingAmountView, choosePortfolioView), PieOrderSheet (pieInvestView, noMoneyView) and EarnScreen are the call sites above.
  • TrackerScreen draws mobileMarketsCopy.detail.chartHint: draw chartHint("touch").
  • The Settings row for Risks and its sublabel are drawn from the app's own strings if they are not read from settingsCopy.risks.description: make the sublabel "What can go wrong".

Added

  • hasLoaded(read) in @noirwire/shared/domain: whether a read has ever come back.
  • balancesView(read), balancesUnavailable(), BalancesView, UnavailableView and Figure in @noirwire/shared/presentation: the one rule for balances that have never loaded.
  • chartHint(pointer) and ChartPointer in @noirwire/shared/presentation.
  • betaView(), BetaView and AboutView.beta; appCopy.beta.
  • portfolioCopy.balances.unavailable.

Fixed

  • A wallet whose balances had never loaded showed a total of "$0.00", "Ready to invest $0.00" and "$0.00" on each portfolio, and Home led with "Add money" as if the wallet were empty. Every figure is now null until the first read succeeds, and the wallet is not called empty.
  • The notice over those zeros said "What you see may be out of date." That wording is now only for balances that did load once; where they never loaded the line is "We couldn't load your balances. Check your connection and try again.", with a retry.
  • Send, Move to portfolio, Buy, Sell, a pie's Invest and Earn could be started against a balance that had never been read. They are held back until it has, with the same reason.
  • The web's Move to portfolio sheet said "Available 0.00 USDC" before the funding wallet had been read.
  • The chart hint told a phone's browser or a tablet to hover.

Changed

Where Was Is
settingsCopy.costs.move "Moving money into a portfolio privately: 0.1% + $0.20. It usually arrives within a minute and can ..." "Moving money into a portfolio privately: 0.1% + $0.20."
settingsCopy.risks.description "What can go wrong, stated once" "What can go wrong"
marketsCopy.detail.chartHint "Hover to see the price and date." (and the phone's "Press and hold ...") { touch: "Press and hold to see the price and date.", mouse: "Hover to see the price and date." }
portfolioCopy.balances.unavailable (new) "We couldn't load your balances. Check your connection and try again."
fundingCopy.leadPrivate, .leadPublic "... Available {amount}." the same, and without the "Available" sentence when available is null
appCopy.beta.tag, appCopy.beta.line (new) ...
Read more

v0.5.3

Choose a tag to compare

@github-actions github-actions released this 04 Oct 17:30

The fixes from a second first-time-user walkthrough. Costs states the trading fee or says there is none; the add-money sheet opens its costs in place, so the address stays on screen; Home, Markets and a tracker's page say their data may be out of date the moment a refresh fails; a missing price is said one way; bringing a wallet back is "Restore" everywhere; the everyday screens use plain words; Earn off the main network is one line; and a cold open with no connection is answered within eight seconds. The test suite lost the cases that only pinned prose and gained rule checks in their place.

Breaking

For both apps:

  • Freshness: homeView takes a sixth, required argument, freshness: { now, balances, prices }. MarketsState and TrackerState take a required freshness ({ now, prices }, and { now, prices, chart }) and no longer take loading. Pass livePricesFreshness() from @noirwire/shared/infrastructure as prices. For balances and for a chart, keep a ReadFreshness (start from NEVER_READ) and move it on with recordRead(previous, ok, Date.now()) each time the read ends, where ok is the result the refresh already answers with (the web's balanceRefresh already has it as whole). now is the screen's own clock: draw again when a refresh ends and at least every REFRESH_INTERVAL_MS. Draw view.stale as the quiet notice and the waiting state while view.loading; delete each app's own stale and first-load logic (BalanceStatus's own flag, refreshFailed on Home, firstLoad on Markets). Refresh balances every REFRESH_INTERVAL_MS while Home is in view and when the app comes back into view: a read older than STALE_AFTER_MS shows the notice.
  • Cold open: run the network gate through checkNetwork(() => connection.getGenesisHash(), expectedGenesisHash()) from @noirwire/shared/application. It answers "ok" | "wrongNetwork" | "unreachable" within NETWORK_CHECK_LIMIT_MS (8 s). The phone's own checkNetwork had no limit, and the web's waited 15 s (CHAIN_CHECK_LIMIT_MS): delete both.
  • Add money: addMoneyView(wallet, { tradeFeeBps }) takes the app's trading fee (noirwireFeeBps()). view.footer is gone. Render view.costs.label ("What does it cost?") as a disclosure that is closed at first (expandedByDefault: false) and shows view.costs.lines under it when tapped. It must not navigate: the sheet and its address stay on screen. The Settings "Costs" page is unchanged.
  • Costs: costsView accepts { tradeFeeBps?: number | null }. settingsCopy.costs.tradeAtReview is gone (tradeNoFee replaces it).
  • A missing price: TrackerRowView.noLivePrice is gone, and price is "Price unavailable right now." when there is no live price: draw the one string, with nothing beside it. TrackerView.price without a live price is { live: false, figure } (no note). marketsCopy.atReview, marketsCopy.noLivePrice, marketsCopy.detail.priceUnavailable and portfolioCopy.home.atReview are gone: use commonCopy.priceUnavailable. marketsCopy.rowLabel takes the price as a string.
  • Tracker page: about is { title, lines, retired } and its title is "About". about.notOffered and about.issuerDetails are gone: the certificate line, the issuer's powers and where it is not offered are now risks.lines, and the issuer link's label is risks.details. Draw all of them behind risks.title ("Read the risks"). marketsCopy.detail.aboutAndRisk is gone.
  • Trade review: tradeReviewView(...).risks is { title, lines }. The web's "What you are buying" block (tradeCopy.tracker.what, .notOffered) moves behind "Read the risks": draw risks.lines there.
  • Earn screen: EarnScreenView.venue and .mainnetOnly are gone. When view.notHere is not null, show that one line at the top and nothing else of the screen's body: rate, deposit, withdraw and total are null and rows is empty. deposit and total are now nullable. Draw view.risks.lines behind view.risks.title on the screen itself; the venue is named there and nowhere above it. The title is view.title ("Earn") with no line under it.
  • Earn sheets: earnSheetView and earnReviewView no longer take venue. earnCopy.lead, .beforeDeposit and .depositRisk are strings, not functions. earnCopy.earning(amount) takes one argument. earnCopy.notHere is gone (use earnScreenView(...).notHere, or earnCopy.mainnetOnly).
  • Network names: networkLabel() answers "Solana" on the main network and "Solana test network" otherwise. Show a network label (the web's nav and banner) only when isMainnet() is false. commonCopy.tradingUnavailableOn and pieCopy.order.tradingUnavailable are strings, not functions of the network.
  • Discard prompt: render discardPromptView(): title, body ("What you typed will be lost."), keep, discard.
  • About: render aboutView(). help.action.url is a mailto: link and website.action.url an https: one: open them on a tap.

For the web app:

  • earnCopy.notHere(networkLabel()) on the Earn page, copy.lead(venue), the venue lines under the title and in the summary, and earnCopy.earning(amount, venue) in PortfolioDetail are the call sites of the Earn changes above.
  • MarketRow, DesktopMarkets, app/markets/page.tsx and DesktopHome read atReview and noLivePrice directly: draw commonCopy.priceUnavailable once in their place.

For the mobile app:

  • TrackerRow draws row.noLivePrice and TrackerScreen draws view.price.note, view.about.notOffered and view.about.issuerDetails: see the tracker changes above.

Added

  • ReadFreshness, NEVER_READ, recordRead, freshnessOf, Freshness, REFRESH_INTERVAL_MS (30 s) and STALE_AFTER_MS (60 s, two refresh intervals) in @noirwire/shared/domain: the one rule for how current a read is. A read may be out of date the moment its latest attempt fails, or once its last success is older than STALE_AFTER_MS; it is current again on the next success; one that never came back, with no failure, is waiting.
  • livePricesFreshness() in @noirwire/shared/infrastructure: when prices last arrived and whether the latest poll failed. POLL_MS is now REFRESH_INTERVAL_MS.
  • freshnessView, FreshnessView, ScreenFreshness, HomeFreshness and TrackerFreshness in @noirwire/shared/presentation. HomeView.stale and .loading; MarketsView.loading; TrackerView.loading. A chart is read once for a range, so only its own failure makes it stale.
  • checkNetwork, NetworkCheck and NETWORK_CHECK_LIMIT_MS (8 s) in @noirwire/shared/application.
  • aboutView(), discardPromptView() and CostsState in @noirwire/shared/presentation. AddMoneyView.costs; EarnScreenView.notHere and .risks; TrackerView.risks.details; the trade review's risks.
  • Copy: commonCopy.discardBody, settingsCopy.costs.tradeNoFee, earnCopy.venueLine.

Fixed

  • Costs no longer says "the fee is shown in the review" where no trading fee is set. It says there is no NoirWire fee, which is what is true.
  • Markets and a tracker's page showed no notice for up to two minutes after the connection went mid-session: the price feed kept the last prices as live and told nobody its poll had failed. The failed poll is now recorded and the notice shows at once, over prices that are still drawn.
  • A cold open with no connection could show nothing for up to 40 seconds on the phone, while the first read was asked for again and again. The check now gives up waiting after 8 seconds and the app says it cannot reach NoirWire.
  • Earn off the main network showed a disabled button and an "Unavailable" for the rate, the total and every row. It now says "Earn runs on the main network." once.
  • A discard confirmation had a title and no body.

Changed

Every string that changed (mobile marks the phone's variant):

Where Was Is
settingsCopy.costs.tradeAtReview "Buying or selling a tracker: the fee is shown in the review." tradeNoFee: "Buying or selling a tracker: no NoirWire fee."
portfolioCopy.addMoney.steps.get.detail "USDC is a digital dollar: 1 USDC = $1. Send it from any app or wallet that supports USDC on the {network} network. You do not need an account with us." "USDC is a digital dollar: 1 USDC = $1. Many apps sell it. Buy it there, then send it on the {network} network...
Read more

v0.5.2

Choose a tag to compare

@github-actions github-actions released this 04 Oct 15:13

The product talks about what a person can do, never about what it lacks or cannot do in the main path: a copy-wide audit rewrote the sentences that led with a limitation, and a test now refuses their return. Alongside it, a round of small pieces both apps had quietly written twice (a step's status word, the chart's price-and-date join, a dialog's close label, a phrase-copy warning) now have one shared home, and Home's emptiness check no longer leads with "Add money" over a wallet that still has money in an archived portfolio or in Earn, unread.

Breaking

For both apps:

  • Home's emptiness rule: homeView takes a fifth, required argument, archivedHeld: ArchivedHeldState ({ holds: boolean; earnUnknown: boolean }). Pass holds: true while any archived portfolio holds a tracker or money moved into it, and earnUnknown: true while any archived portfolio's Earn position has not been confirmed as zero (still loading, or its read failed). Either one now keeps Home from leading with "Add money" over a wallet that is not really empty. The web app can delete its own homeOverHeldMoney wrapper and walletIsEmpty in src/components/portfolio/homeState.ts and call homeView directly with the two booleans it already computes for them. Separately, homeView no longer treats an unread Earn total (earn === null) as confirmed zero: a wallet whose active-portfolio Earn figure has not come back yet now keeps both buttons instead of assuming there is nothing to show.
  • chartReadout(...) answers text, the price and date already joined ("$100.00 · 14 Jul 2026, 12:00"). The web app's PriceChart built this join itself (`${readout.price} · ${readout.date}`); read .text and delete the local join.
  • Removed from the add-money explainer: the card-payments sentence is gone from portfolioCopy.addMoney.steps.get.detail (see Changed).

Added

Pieces reported by both apps as written twice, now with one shared home (a phone variant only where the wording truly differs):

  • commonCopy.showLabel, .hideLabel (a reveal control's accessible name), .increaseLabel, .decreaseLabel (a stepper's accessible name), .percentSpoken (a stepper's spoken value), .closeLabel (a close control's accessible name where plain "Close" is ambiguous), .nothingHereYet, .discardThis, .keepEditing, .discard (a sheet's unsaved-changes prompt), .stepStatus (a progress step's status word: waiting, in progress, done, failed, not done), .andList (joins a list the way a sentence does: "SOL", "SOL and USDC", "SOL, USDC and SPYx"). commonCopy.back and .close already covered the plain labels; apps reading a local "Back" or "Close" string should read those instead.
  • marketsCopy.detail.chartHint ("Hover to see the price and date.") and mobileMarketsCopy.detail.chartHint ("Press and hold to see the price and date."): the one-line hint under a tracker's chart.
  • mobileOnboardingCopy.phrase.wordLabel(position, word), the accessible name for one word of the recovery-phrase grid, and .phrase.copy, the phrase-copy warning (confirmTitle, confirmBody, copyAnyway, copied, copiedNote, failed) a phone warns with before copying a recovery phrase to the clipboard. onboardingCopy.phrase.reveal and .hidden already covered the reveal button and the concealed word; the mobile app's own PhraseGrid.tsx duplicates both verbatim today and should read them instead.
  • mobileWalletCopy.protection.refused: what stands in place of a secret (an address, a recovery phrase) the phone could not confirm is kept out of screenshots and recordings.
  • mobileSendCopy.camera: the camera-permission screen that stands in for the scanner (purpose, allow, off, offDetail, openSettings).
  • mobileAppCopy.runtimeFailure: what the phone says when its own security checks fail on startup, before anything else opens (title, detail, configFailure, closing).

Changed

The owner's rule on copy: the product states the working route as steps, and never volunteers a limitation in the main path (onboarding, Welcome, Home, add money, funding, markets, trade, Earn, send). A network a feature does not reach now says where it does run, the way errorsCopy.trade.mainnetOnly already did for Live trading's own refusal message. Every string that changed:

Where Was Is
portfolioCopy.addMoney.steps.get.detail "USDC is a digital dollar: 1 USDC = $1. NoirWire cannot take card payments yet. Buy USDC in any app or service that can send it on the {network} network. No account with us is needed." "USDC is a digital dollar: 1 USDC = $1. Send it from any app or wallet that supports USDC on the {network} network. You do not need an account with us."
commonCopy.tradingUnavailableOn "Live trading is unavailable on {network}. You can explore trackers, but cannot place an order here." "Live trading runs on Solana mainnet. You can explore trackers on {network}."
errorsCopy.trade.mainnetOnly "Live trading is only available on mainnet." "Live trading runs on mainnet."
errorsCopy.earn.mainnetOnly "Earning is only available on Solana mainnet." "Earning runs on Solana mainnet."
earnCopy.mainnetOnly "Earn is available on Solana mainnet only." "Earn runs on Solana mainnet."
earnCopy.notHere "Earn is available on Solana mainnet only. Nothing can be lent or withdrawn on {network}." "Earn runs on Solana mainnet. Switch from {network} to lend or withdraw."
pieCopy.order.tradingUnavailable "Live tracker trading is unavailable on {network}. The pie is saved, and can be invested on mainnet." "The pie is saved. Live tracker trading runs on mainnet; switch from {network} to invest it."

tests/unit/copyVariants.test.ts gained PRODUCT_LIMITATION_PHRASES and productLimitationsIn, alongside the existing REPLACED_NAMES: a test refuses the card-payments sentence and the "unavailable on" / "mainnet only" / "only available on" / "not supported" phrasings wherever they reappear in the everyday copy (the Privacy and Risks pages are excluded, same as the existing name check).

v0.5.1

Choose a tag to compare

@github-actions github-actions released this 04 Oct 13:36

The wording a first-time user meets, and the view models that carry it, so both apps draw the same Welcome, empty Home, add-money sheet, Costs, tracker page and import. One name for each thing on both platforms. Both apps have rendering work to do: see Breaking.

Breaking

For both apps:

  • Welcome: render welcomeView(platform). onboardingCopy.welcome.lead, .import, .lookAround, .trustMainnet, .trustTestNetwork and mobileOnboardingCopy.welcome are gone; .lines, .restore, .explore and .trust replace them. Only create is a filled button. The server explanation is no longer on this screen (it stays in Settings, Privacy). A development-only link such as "Open the UI kit" is the app's own: keep it out of Welcome's main column and out of every build that is not a development build.

  • Home: HomeView.secondary may be null (it is while the wallet is empty: the one button is "Add money"). HomeView.howTo is gone: open the add-money sheet and render addMoneyView(wallet) there. HomeTarget no longer has { to: "receive"; reveal }; it has { to: "addMoney" }. Draw total.explainer ("Only you see this total") as the tappable line, explanation under the button when it is not null, and no header arc when showArc is false.

  • Add money: render addMoneyView(wallet): its title, three steps, the address block inside step 2 (shown at once, no reveal tap, with Copy and network), and the footer link, which opens Costs. address.captureAllowed is true: allow a screenshot of this sheet, and keep capture protection on the recovery phrase, the password, import and portfolio addresses.

  • Costs: add a Settings row settingsCopy.costs.title and a page or sheet that renders costsView({ tradeFeeBps }), passing noirwireFeeBps() from @noirwire/shared/infrastructure. Open the same view from "What does it cost?".

  • Tracker page: TrackerState has a new required smallestOrderUsd (pass gaslessFromUsd(), the figure the trade sheet already uses). TrackerView.caption is now "NVIDIA tracker · NVDAx"; draw follows under it, minimum near Buy, stale as the quiet notice, the chart's high and low, and risks.lines behind risks.title ("Read the risks"). about.readRisks moved to risks.title, and the issuer's powers moved from about.lines to risks.lines. On press and hold, show chartReadout(points, x, { range, readAt }).

  • Markets: draw MarketsView.stale as the same quiet notice. Pass loading: true while prices are read for the first time, on Markets and on a tracker's page, so the notice does not show over a first load.

  • Cold open with NoirWire out of reach: render unreachableView({ hasWallet, locked }), and when unlockOffered is true show the unlock screen with the message as a notice instead of replacing it. unlock() and unlockWithKeyBits() read only the device's own storage and never waited on the network; what held unlocking back is each app's network gate, which must let the unlock screen through. After unlock, with NoirWire still out of reach, the view answers the balances wording, and every signature is still refused by the network check that runs before it.

  • Unlock: render unlockProblemView(problem, platform). Never clear the password field on a failed unlock; when typed is "keepSelected" (a wrong password) select the text.

  • Password: render newPasswordView(platform).rule above the field, before anything is typed. onboardingCopy.password.intro and mobileOnboardingCopy.password.intro are now functions of the minimum length; the view model fills it in.

  • Import: show importWaitingView(...).note under the button for as long as an import runs. While lookFurtherView(...).continuePaused is not null, disable Continue and show that line. When importSourceView(resolution).skipped is not null, do not show the choice of addresses: go on with skipped.scheme and say skipped.line.

  • Buying with no money: call noMoneyView(reads, wallet, portfolioId) when Buy is tapped (portfolioId null from a tracker's page with no portfolio chosen yet). When it is not null show its title, detail and button in place of the trade's first step.

  • Receive: receiveView no longer takes platform (both platforms say the same). Remove it from the call.

  • Funding: fundingReviewView(...).confirm.label is "Move privately". fundingWalletView(...).showAddress is now addMoney, and fundingAmountView(...).emptyNotice.action reads "Add money": both open the add-money sheet.

  • Import pacing: IMPORT_REQUESTS_PER_SECOND is 3 (was 8). Pacer has two new required methods, slowDown(holdMs) and hold(holdMs). A pacer from createPacer has both; one written by hand for paceImportWith needs them. A further look can now run past a minute while it waits for the session's allowance: keep showing lookFurtherView(...).continuePaused and a way to cancel for as long as it runs.

  • Removed copy keys (each app reads some of these directly):

    • portfolioCopy.home: findStocks (use findTrackers), addUsdc, showFundingAddress, howToAddMoney, cashAvailable and cashToInvest (use readyToInvest).
    • portfolioCopy.detail: addMoney (use moveToPortfolio).
    • portfolioCopy.addMoney: stepGet, stepGetDetail, stepSend, stepSendDetail, stepMove, stepMoveDetail, depositAddress, footnote (use addMoneyView).
    • mobilePortfolioCopy.receive (use portfolioCopy.receive.publicNote).
    • marketsCopy.detail: liveNote, quoteNote (use finalPrice), issuerControl (use publicTrades and issuerPowers). mobileMarketsCopy.detail: liveNote, dividends.
    • mobileFundingCopy: title (use fundingCopy.titlePrivate), cash (use commonCopy.readyToInvest).
  • Renamed copy keys:

    Was Is
    onboardingCopy.welcome.import .restore
    onboardingCopy.welcome.lookAround .explore
    onboardingCopy.welcome.lead .lines (two lines)
    commonCopy.cashAvailable commonCopy.readyToInvest
    portfolioCopy.home.dayIndicative, .heldDayIndicative .dayApproximate, .heldDayApproximate
    portfolioCopy.home.togetherOnlyHere .onlyYouSee
    portfolioCopy.home.shelves.stocks .shelves.companies
    portfolioCopy.earnPromo.waitingCash .waitingMoney
    portfolioCopy.detail.cashToInvest .readyToInvest
    portfolioCopy.detail.moveMoneyHere .moveToPortfolio
    portfolioCopy.detail.addMoneyFirst .moveMoneyFirst
    networkCostCopy.moveMoneyHere .moveToPortfolio
    fundingCopy.noPrivateRoute .noPrivateMove
    fundingCopy.depositLink .addressLink
    mobileFundingCopy.showAddress, .page.showAddress .addMoney, .page.addMoney
    marketsCopy.liveIndicative .approximatePrice
    marketsCopy.detail.indicative .detail.approximate
    marketsCopy.detail.noChart(range) .detail.noChart (a string)
    tradeCopy.moreThanCash, .priceAboveCash .moreThanReady, .priceAboveReady
    tradeCopy.noCash, .noCashDetail .noMoney, .noMoneyDetail
    pieCopy.order moreThanCash, keepAsCash, noCash, noCashDetail moreThanReady, keepAsUsdc, noMoney, noMoneyDetail
    earnCopy.noCash, .cash, .terms.leavesCash .noMoney, .ready, .terms.leaves

For the web app:

  • welcomeView("web").example holds the example portfolios, the three steps and the footnote the page shows beside the column.
  • fundingTitle(asset, false) reads "Move SOL publicly".

For the mobile app:

  • welcomeView("mobile").example is null.
  • About: show mobileSettingsCopy.about.help with .helpContact, and .website with .websiteValue.
  • Polish that is the app's own to draw: "Add at least one tracker." once; "Type a name first." cleared when the tab changes; sendReason ("Nothing to send yet.") under Send and not under Receive; inner padding on a pie's percentage; "Move to portfolio" the same size on a portfolio and on a pie.

Added

  • welcomeView(platform), addMoneyView(wallet), costsView({ tradeFeeBps }), privateMoveCostText(), `unreachableV...
Read more

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 08:35

Every request now goes to one server, NoirWire's own, and carries an anonymous session. Both apps must change how they boot.

Breaking

For both apps:

  • Platform has a new required port, sessionStore: get, set and remove of one small JSON value in plain app storage. Not the vault, and never inside the wallet record.

  • Env has a new required value, apiBaseUrl, and envFrom refuses settings without it. EnvSettings gains apiBaseUrl, platform, development and rpcUrl.

  • configureHttp, HttpConfig and its headers() are gone. There is nothing to call in their place: where requests go is env.apiBaseUrl, and the one header every request carries is added by the package. A server or a test that named an RPC provider with configureHttp({ rpcUrl }) now sets rpcUrl in the environment.

  • RPC_RELAY_PATH, JUPITER_RELAY_PATH and PRIVATE_PAYMENT_RELAY_PATH are gone. An address is built with apiUrl(route, rest).

  • The paths changed, so the web app's own relay routes are no longer called by this package:

    Was Is
    POST /api/rpc POST /v1/rpc
    /api/jupiter/* /v1/jupiter/*
    POST /api/private-payments/* POST /v1/private-payments/*
    GET and POST /api/relayer GET and POST /v1/relayer
    GET /api/prices GET /v1/prices
    GET /api/history/:symbol/:range GET /v1/history/:symbol/:range
    POST /api/event (each app's own) POST /v1/events, as apiUrl("events")
  • Every one of those requests carries Authorization: Bearer <token>. An app's own requests to the server go through authorizedFetch to carry it too.

  • A new failure code, notAvailableNow, in ChainErrorCode: a switch over the codes that lists them all needs the new case. chainErrorMessage already words it.

  • memoryPlatform() now includes a sessionStore, and testEnv() an apiBaseUrl (https://api.noirwire.test). A test that pinned a relative path such as /api/jupiter/swap/v2/order now sees https://api.noirwire.test/v1/jupiter/swap/v2/order. A test setup calls installTestPlatform() in place of installPlatform(memoryPlatform()) and configureHttp(...), or its first request tries to start a real session.

  • WAIT_LIMIT_MS replaces each app's own table, and where the two differed the longer stands: a check may now run 30 s on the phone (was 20 s) and an action 120 s on the web (was 90 s).

For the web app:

  • Set apiBaseUrl to /api with platform: "web", and have the host forward /api/:path* to the server; or name the server's origin and allow it in connect-src.
  • Install a sessionStore over localStorage, and keep the cross-tab locks: a session's renewal runs under the lock noirwire-session.
  • Delete src/components/wallet/passwordCheck.ts's copy of the rule and call assessPasswordWith(checker, password) with the bundled checker. Delete src/components/localCopy.ts and WAIT_LIMIT_MS in src/components/waiting/limits.ts; their words and numbers are here now (see Added).
  • The server-side platform passes rpcUrl to envFrom, where it passed it to configureHttp.

For the mobile app:

  • Set apiBaseUrl to the server's origin (https://api.noirwire.com), in place of the relay URL. Plain http is accepted only for localhost, 127.0.0.1 and 10.0.2.2, and only with development: true. A path is refused on the phone.
  • Delete the X-NoirWire-Client header and the HTTP configuration that carried it (src/platform/httpConfig.ts): nothing reads headers() any more.
  • Install a sessionStore over the app's plain key-value storage.
  • Delete src/features/phoneCopy.ts and WAITING_LIMIT_MS in src/ui/useWaiting.ts; their words and numbers are here now.

Added

  • apiUrl(route, rest?) in @noirwire/shared/infrastructure: the one function that builds a request's address, from env.apiBaseUrl and the server's paths (session, rpc, jupiter, privatePayments, relayer, prices, history, events, health). apiBaseUrl is an origin, or on the web a path on the page's own origin.
  • The anonymous session. createSessionKeeper in @noirwire/shared/application starts one (POST /v1/session, no token, no body), keeps it through sessionStore, renews it a minute before its token runs out (POST /v1/session/refresh) and whenever the server turns it down, shares one start or renewal among callers that arrive together and, under the platform lock, among tabs, starts a new one when a renewal is refused or the server answers session_expired, and replaces one older than SESSION_MAX_AGE_MS (24 hours; env.sessionMaxAgeMs sets another). Storage is the truth for every tab: what a tab holds is checked against it before each use, and a drop takes the same lock as a renewal, so a session dropped in one tab is not brought back or used on by another. A session request is given SESSION_REQUEST_TIMEOUT_MS (10 s), and after a failure nothing more is asked for a growing, jittered pause (SESSION_RETRY, a minute at most), during which callers are told at once. The session is a quota bucket, not an identity: it is not derived from the wallet, it rotates daily, and a wallet reset drops it.
  • authorizedFetch(input, init) in @noirwire/shared/infrastructure: fetch with the session's token. After a 401 a read or an unsigned build is made once more with a renewed session; a request that hands over a signed transaction, or asks the relayer to sign one, never is. dropSession(), keepSessionWith() and sessionRoutes beside it.
  • errorsCopy.chain.notAvailableNow, said the same on both platforms: "We can't do this right now. Nothing was sent, and your money has not moved. Try again." It is what a person reads when no session could be had, so a request was never made, or when a read was turned down twice for its session.
  • ApiError, API_ERRORS, ApiErrorCode and apiErrorIn in @noirwire/shared/domain, and apiErrorOf(response) in @noirwire/shared/infrastructure: every error the server writes itself, { code, error }, read by its code and never by its sentence. isTransient asks one again by its code. A provider's own error passes through as it came.
  • npm run test:api: every client held to the server's OpenAPI file, named by NOIRWIRE_OPENAPI; it is part of npm test and skipped there, saying so, when the variable is unset. npm run test:api:live: the read paths against a running server named by NOIRWIRE_API_URL.
  • In @noirwire/shared/testing: installTestPlatform(overrides?), fakeSession(), memorySessionStore(), fakeApi(routes) to answer the server by path, and TEST_API_URL.
  • assessPasswordWith(checker, password) in @noirwire/shared/wallet, with PasswordChecker and PasswordAssessment: the password rule as a synchronous function over a checker the app already holds, so a screen that bundles the checker runs the same rule with nothing loaded on demand. assessPassword is built on it.
  • WAIT_LIMIT_MS in @noirwire/shared/presentation: how long each kind of wait may run before a screen ends it (content 20 s, check 30 s, review 30 s, action 120 s).
  • The words both apps kept for themselves: waitingCopy.overdue, waitingCopy.actionHeld, waitingCopy.gettingReady and mobileWaitingCopy.overdue; onboardingCopy.import.notNow and onboardingCopy.phrase.discarded, .newPhrase, .acknowledgeNew; portfolioCopy.balances and portfolioCopy.archived; marketsCopy.pricesUnavailable; appCopy.offline; earnCopy.unread and earnCopy.notHere; commonCopy.tryAgain; mobileWalletCopy.newPassword.checkFailed; mobilePortfolioCopy.create.forExample and .nameNeeded.

Fixed

  • A relayer-paid send or Earn move that landed is no longer reported as "did not go through, safe to try again" when the app is closed after the broadcast. The signed transaction the relayer returns (now { transaction, signature }, signing only) is checked, its id is written into the reservation, and only then is it sent. A reservation with no recorded id is never released on its blockhash alone: the signer's recent transactions are searched for it first (signer and ownSignature on the pending action; unfindable when the chain cannot be searched, which only the person can then clear).
  • Cash no longer reads as it did before an action whose network cost it paid: the cost is taken off locally the moment a tracker send lands, and off an Earn move whose balances could not be read back.
  • "Max" on an Earn withdrawal takes the whole position back by its shares (the Lend program's redeem), where it asked for a USDC amount that could be a few units more than the chain would give and failed. Nothing is left behind. Any withdrawal that asks for what the position is worth or more is that redemption.
  • No raw chain or program text reaches a person. A failed simulation is worded in one place (simulationRefusal), and failureMessage replaces any account that carries JSON, an instruction error or a program log with the plain words for what the action was doing (saysRawChainError).
  • A fee payer that cannot pay the network reads as the relayer not being usable ("The network cost could not be covered in USDC right now. Nothing was sent."), not as "InsufficientFundsForFee".
  • A tracker sent from a portfolio with no cash says the portfolio needs cash for the network cost. It is decided before the relayer is asked, whose failed simulation used to read as "not available".
  • The funding form states its fees to the same decimal as its review: 0.025 and 25.225, not 0.03 and 25.23.

Changed

  • reviewSend reads the recipient from the network and answers recipient beside t...
Read more

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 03 Oct 23:12

Added

  • The waiting standard. waitingView(elapsedMs, kind, steps?) in @noirwire/shared/presentation says what to show as time passes: nothing before WAITING_DELAY_MS (300 ms), then a quiet signal, then a calm "still working" line after STILL_WORKING_AFTER_MS (4 s for content, a check or a review; 8 s for an action), and which step of multi-step work is current. A pure function of elapsed time: each platform owns its clock. importWaitingView and importFailedText do the same for an import, with IMPORT_LAST_STEP_AFTER_MS (6 s).

  • waitingCopy, and the import's progress words (onboardingCopy.import.progress, with the phone's lead and failure in mobileOnboardingCopy.import.progress).

  • Quiet retries for reads: withRetries, readWithRetries, READ_RETRY, isTransient in @noirwire/shared/application. Three tries, 400 ms and then 800 ms apart, on a request that never got through, ran out of time or came back 429 or 5xx. Wrapped: the balance refresh, live prices, price history, tracker multipliers, the lending rate and position, import lookups, checkRecipient, and preparing a review (a trade's price, a send's cost, whether a holding is open, the relayer's price and keys). Nothing that signs or submits is retried, and a refusal never is.

  • mobileSettingsCopy and mobileWalletCopy: the phone's Settings, Unlock and Reset words, as variants. unlockProblemText and isWrongPassword in @noirwire/shared/presentation.

  • walletCopy.reset.notRemoved, walletCopy.crossTab.notice and walletCopy.crossTab.refused, which the web app kept for itself.

  • decimalAmount in @noirwire/shared/domain: a period or a comma as the decimal separator, and null for text that could be read two ways.

  • signAsClient and unsignedTransaction in @noirwire/shared/testing, for a test's stand-in chain client to sign through the real signing guard. The testing layer may now import infrastructure/ for that.

  • The phone's layout tokens in @noirwire/shared/design: mobileRadius, layout, size, MIN_TARGET, opacity, overlayColor, fonts, motion, and space[8].

  • failureAccount in @noirwire/shared/presentation: a failure in its own account, which is what it is counted under.

  • lookFurtherForPortfolios(mnemonic, scheme, activity) in @noirwire/shared/infrastructure, and lookFurtherView with onboardingCopy.import.lookFurther ("Missing a portfolio? Look further"): a second scan a person can ask for from an import's result. It carries on from where the first stopped (SchemeActivity.scannedThrough) and gives up only after EXTENDED_DISCOVERY_GAP (100) unused addresses in a row.

  • canCreatePortfolio and unusedPortfoliosInARow in @noirwire/shared/application, and the constants DISCOVERY_GAP (20), EXTENDED_DISCOVERY_GAP and MAX_UNUSED_PORTFOLIOS_IN_A_ROW (10) in @noirwire/shared/domain.

  • createPacer in @noirwire/shared/application, a rate pacer with an injectable clock, and paceImportWith and IMPORT_REQUESTS_PER_SECOND (8) in @noirwire/shared/infrastructure. withRetries takes jitter and random.

  • MAX_ACTIVITY_ENTRIES (500) in @noirwire/shared/domain, olderNotKept on the Activity list view, and its words for both platforms.

  • focusRing and the focus token in @noirwire/shared/design; findStock and phraseWords in @noirwire/shared/infrastructure; canonicalSymbol on the catalog; tooPrecise and smallestAmount in @noirwire/shared/domain; portfolioNameTaken in @noirwire/shared/application; privacySectionHelp in @noirwire/shared/presentation; LOCK_SIGNAL_KEY in @noirwire/shared/wallet.

Changed

  • lock() locks the wallet everywhere it is open: it announces the lock through the vault, under noirwire.wallet.lock (a counter, nothing about the wallet), and every other tab or running copy locks on hearing it. A reset does the same when the record would not be removed. An idle lock stays with the tab that went idle.
  • changePassword refuses the password it already has: "That is already your password."
  • Every button variant, the icon button, the input and the chip carry a keyboard focus ring in a colour named outright (focusRing).
  • Every counted noun goes through plural: "1 asset", "1 result", "Order placed".
  • sendCopy.invalidAmount and mobileSendCopy.invalidAmount: "Enter a finite amount greater than zero." and "Enter an amount greater than zero." are now "Enter an amount, like 12.50."
  • An amount typed with more decimals than its asset has, which includes one below its smallest unit, is refused in a send, when moving money in, in Earn and in a trade: "That amount has too many decimals. The smallest amount is 0.000001 USDC."
  • parseRecoveryPhrase reads a phrase as people paste one (numbered lists, commas, line breaks, tabs, capitals) and says exactly what is wrong: how many words there were, which word is not a recovery phrase word and where it stands, or that the words do not form a phrase. It answers problem beside error.
  • stockBySymbol and the catalog's asset find a tracker whatever the capitals: nvdax is NVDAx.
  • createPortfolio refuses a name another portfolio already has, archived ones included, with duplicateName.
  • fundingAmountView takes touched and shows no error under an amount field nobody has typed in.
  • createPortfolio refuses with unusedPortfolios ("You have several portfolios that were never used. Use one of those first. An archived one can be restored.") once the wallet ends in ten portfolios in a row that never held or did anything, archived ones included.
  • An import asks about each candidate address in one request instead of two, paces all its requests to eight a second across both sets of addresses, and adds jitter to its pauses after a refusal.
  • The wallet's record keeps its 500 most recent activity entries. The oldest are dropped as new ones are written.

Failure and waiting messages say what happened, what it means for the money and what to do next, and no longer say how the app asked. This changes what the web says too.

String Was Is
onboardingCopy.import.networkFailed Could not reach the network to check balances. Try again. We couldn't finish importing your wallet. Nothing was saved in this browser. Try again.
onboardingCopy.import.checking Checking balances and recovering portfolios onchain... Finding your portfolios...
onboardingCopy.password.encryptFailed Could not encrypt the wallet. Try again. We couldn't encrypt your wallet, so nothing was saved. Try again.
mobileOnboardingCopy.import.networkFailed Could not reach the network to check this phrase. Try again. We couldn't finish importing your wallet. Nothing was saved on this phone. Try again.
mobileOnboardingCopy.import.offline You're offline. Importing needs the network to find what this phrase holds. You're offline. Nothing was saved on this phone. Go back online to import your wallet.
appCopy.networkGate.wrongNetwork(n) This app is built for n, but its network connection serves a different chain. Nothing can be sent until that is fixed. NoirWire is not connected to n as it should be. Your money has not moved, and nothing can be sent until this is fixed. Try again later.
appCopy.networkGate.unreachable The network could not be reached, so balances cannot be shown safely. We can't show your balances right now. Your money has not moved. Try again.
mobileAppCopy.network.checking Checking the network... Getting things ready...
errorsCopy.chain.noQuote Jupiter has no price for this order right now. There is no price for this order right now. Nothing was traded. Try again in a moment.
errorsCopy.chain.wrongNetwork The network connection serves a different chain than this app is built for. Nothing was signed or sent. NoirWire is not connected to Solana as it should be, so this was stopped. Nothing was signed or sent....
Read more