Skip to content

v0.5.1

Choose a tag to compare

@github-actions github-actions released this 04 Oct 13:36
· 14 commits to main since this release

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(), unreachableView({ hasWallet, locked }), unlockProblemView(problem, platform?), newPasswordView(platform?) and noMoneyView(reads, wallet, portfolioId) in @noirwire/shared/presentation, with their types.
  • chartReadout(points, x, { range, readAt }) and chartHighLow(points) in @noirwire/shared/presentation: the price and date under a finger held on a chart (x from 0 at the left edge to 1 at the right), and a range's high and low. A series carries prices and no times, so the date is worked out from the range's span and the moment the series was read.
  • On existing view models: HomeView.showArc, .explanation and .total.explainer; MarketsView.stale; TrackerView.follows, .minimum, .stale, .risks and the chart's .high and .low; ImportWaitingView.note; LookFurtherView.continuePaused; ImportSourceView.skipped.
  • What things cost is read from the constants the reviews charge by and typed nowhere else: a private move's "0.1% + $0.20" from PRIVACY_FEE_BPS and RELAY_FEE_RAW, the trading fee from the app's own feeBps, the password's "at least 12 characters" from MIN_PASSWORD_LENGTH (now in @noirwire/shared/domain, where the strength check reads it too), and a tracker's smallest order from the figure the trade sheet holds an order to. Where no trading fee is set, Costs states no number and says the fee is shown in the review.
  • RANGE_SPAN_MS in @noirwire/shared/domain; isRateLimited in @noirwire/shared/application; RATE_LIMIT_PATIENCE_MS in @noirwire/shared/infrastructure; ApiError.retryAfterMs, read from the server's Retry-After; privateMoveTiming beside fundingCopy.
  • Copy: settingsCopy.costs; portfolioCopy.home.moneyArrives, .onlyYouSee, .readyToInvest; portfolioCopy.addMoney.steps, .network, .costsLink; portfolioCopy.receive.fundingNotice; fundingCopy.confirmPrivate; marketsCopy.stale; marketsCopy.detail.trackerLine, .follows, .smallestOrder, .finalPrice, .publicTrades, .issuerPowers, .high, .low; appCopy.networkGate.cannotReach; onboardingCopy.import.waitingNote, .newEmptyWallet, .lookFurther.continuePaused; tradeCopy.noMoneyAnywhere; mobileSettingsCopy.about.help, .helpContact, .website, .websiteValue.
  • tests/unit/copyVariants.test.ts refuses every replaced name in every copy object on both platforms (see the vocabulary below), allows "Cash" only as a row label, and refuses any mention of an exchange.

Fixed

  • An import and "Look further" stay inside what the server allows one session, and wait when it is spent instead of failing. The server gives a session 240 chain reads a minute in production (120 on a developer's machine) and sends the provider eight a second for everyone together; both scans ran at eight a second, which spends a minute's allowance in 30 seconds, after which every lookup was refused and the scan gave up. They now run at three a second (IMPORT_REQUESTS_PER_SECOND): 180 a minute, inside the production allowance with 60 a minute left for the app's other reads. A lookup answered 429 is no longer one of four tries: it is waited out, for as long as the server asks (Retry-After) or a pause that grows from one second to fifteen, the pace is halved once (never below a quarter of the starting rate), and the scan goes on from the lookup it stopped at. Only a lookup still refused after 90 seconds (RATE_LIMIT_PATIENCE_MS) fails the scan, which keeps what it had read for the next attempt. Against a server allowing four a second and 120 a minute, an import followed by a further look completes across the minute boundary without a failure; against eight a second and 240 a minute neither is refused at all.

Changed

  • One sentence for how long each wait is, read from what the code does. A private move: "It usually arrives within a minute and can take a few." (privateMoveTiming), in the add-money step, in Costs and on the funding sheet on both platforms; the service is asked to deliver within 2 to 15 seconds and the app watches for a minute before it reports the move as still settling. An import: "This can take up to a minute.", under the button and in the progress lead; a phrase with nothing on it is 42 lookups at three a second, 14 seconds, and every portfolio found adds up to 20 more.

  • One vocabulary on both platforms. Bringing money in from outside is "Add money"; moving it from the funding wallet into a portfolio is "Move to portfolio", confirmed with "Move privately", and the noun is "private move"; the place money first arrives is the "funding wallet" (an address is what is copied); the money is USDC, and "Cash" stays only as the row label for what a portfolio holds uninvested; what a person holds is a "tracker"; a shown price is "Approximate". The Privacy and Risks pages (settingsCopy.protection, settingsCopy.risks, mobileSettingsCopy.privacy, mobileSettingsCopy.risks) are as they were, "private funding" and "private transfer" included.

    Every string that changed, as it reads now (mobile marks the phone's variant; a key in the Is column was renamed):

    Where Was Is
    appCopy.description "Purpose-based private portfolios for crypto and tokenized stocks." "Private portfolios for US stock trackers."
    appCopy.installDescription "Invest in tokenized US stocks from a wallet only you hold." "Invest in US stock trackers from a wallet only you hold."
    commonCopy.cashAvailable "$X cash available" readyToInvest: "$X ready to invest"
    commonCopy.tradingUnavailableOn "Live stock trading is unavailable on X. You can explore investments, but cannot place an order here." "Live trading is unavailable on X. You can explore trackers, but cannot place an order here."
    onboardingCopy.welcome.title "Keep your investing separate from your everyday wallet." "Invest in US stock trackers. Privately."
    onboardingCopy.welcome.lead "Each portfolio is its own address. Fund it through the private route and nothing on chain ties it to your funding wallet. The trades themselves still happen in public." lines: "Trackers follow share prices like Apple, Tesla or the S&P 500. You do not own the shares." and "Each portfolio is separate from your funding wallet. Trades themselves are public."
    onboardingCopy.welcome.create "Create my wallet" "Create a wallet"
    onboardingCopy.welcome.import "Import an existing wallet" restore: "Restore a wallet"
    onboardingCopy.welcome.lookAround "Look around first" explore: "Explore trackers"
    onboardingCopy.welcome.trustMainnet, .trustTestNetwork, mobileOnboardingCopy.welcome.trust "Your keys and recovery phrase stay ___. Network requests go through NoirWire's own server..." onboardingCopy.welcome.trust: "No account and no ID check. Only your recovery words can restore your wallet."
    onboardingCopy.welcome.steps[2] "Add USDC, then invest in stock trackers" "Add money, then invest in trackers"
    onboardingCopy.import.importedIntro "...Fund it whenever you're ready." "...Add money whenever you're ready."
    onboardingCopy.password.intro "Your recovery phrase is encrypted with this password before it is stored. We never see it and it is never sent anywhere. Anyone who copies this browser's data can try to guess it, so it has to be hard to guess." "Choose a password of at least 12 characters. It locks the wallet in this browser. We never see it."
    mobileOnboardingCopy.password.intro "Your wallet is encrypted with this password before it is stored on this phone. We never see it and it is never sent anywhere. Anyone who gets a copy of this phone's data can try to guess it, so it has to be hard to guess." "Choose a password of at least 12 characters. It locks the wallet on this phone. We never see it."
    mobileOnboardingCopy.result.nothing "Nothing was found on chain for these addresses yet. Add money whenever you're ready." "Nothing found yet. This phrase will open a new, empty wallet."
    mobileOnboardingCopy.result.showAddress "Show funding address" "Show my funding wallet address"
    mobileOnboardingCopy.result.addressLabel "Funding address" "Funding wallet address"
    portfolioCopy.home.findStocks "Find stocks" removed; findTrackers: "Find trackers"
    portfolioCopy.home.dayIndicative "24h indicative" dayApproximate: "24h approximate"
    portfolioCopy.home.heldDayIndicative "held assets · 24h indicative" heldDayApproximate: "held assets · 24h approximate"
    portfolioCopy.home.heldTrackersDay "X held trackers · 24h indicative" "X held trackers · 24h approximate"
    portfolioCopy.home.cashToInvest, .cashAvailable "Cash to invest", "Cash available to invest" readyToInvest: "Ready to invest"
    portfolioCopy.home.shelves.stocks "Stocks" shelves.companies: "Companies"
    portfolioCopy.home.togetherOnlyHere "Shown together only here" onlyYouSee: "Only you see this total"
    portfolioCopy.home.addUsdc "Add USDC" removed; addMoney: "Add money"
    portfolioCopy.home.showFundingAddress "Show funding address" removed: the address is in the add-money sheet
    portfolioCopy.home.howToAddMoney "How to add money" removed; portfolioCopy.addMoney.title: "Add digital dollars"
    portfolioCopy.home.portfolioLine.holdings "$X cash · 2 holdings" "$X to invest · 2 holdings"
    portfolioCopy.home.portfolioLine.pie "Pie · 2 trackers · $X cash" "Pie · 2 trackers · $X to invest"
    portfolioCopy.earnPromo.couldBeEarning "Your cash could be earning" "Your USDC could be earning"
    portfolioCopy.earnPromo.couldEarn "Your $X in cash could earn about $Y a year." "Your $X could earn about $Y a year."
    portfolioCopy.earnPromo.waitingCash "Cash that is waiting to be invested can earn in the meantime." waitingMoney: "Money that is waiting to be invested can earn in the meantime."
    portfolioCopy.detail.cashToInvest "X USDC cash to invest" readyToInvest: "X USDC ready to invest"
    portfolioCopy.detail.moveMoneyHere, .addMoney "Move money here", "Add money" moveToPortfolio: "Move to portfolio"
    portfolioCopy.detail.nothingMoved "Nothing has moved yet. Fund or receive into this portfolio to begin." "Nothing has moved yet. Move money into this portfolio to begin."
    portfolioCopy.detail.addMoneyFirst "Add money first, then choose a tracker." moveMoneyFirst: "Move money in first, then choose a tracker."
    portfolioCopy.receive.publicNote "...Use the private funding flow to move value in from your own funding balance instead." "...To move in your own money, use Move to portfolio instead."
    mobilePortfolioCopy.receive.portfolioNotice "...To move in your own money, use Add money instead." removed; the phone says portfolioCopy.receive.publicNote
    portfolioCopy.receive.fundingTitle "Your funding address" "Your funding wallet address"
    portfolioCopy.receive.hiddenFunding "Your funding address is hidden." "Your funding wallet address is hidden."
    portfolioCopy.receive.qrFunding "QR code of your funding address" "QR code of your funding wallet address"
    portfolioCopy.receive.afterArrival "Once it arrives, move it into a portfolio through the private route." "Once it arrives, choose a portfolio and tap Move to portfolio."
    portfolioCopy.addMoney.title "Add money" "Add digital dollars"
    portfolioCopy.addMoney.moveTo "Move money to X" "Move to X"
    portfolioCopy.addMoney.stepGet, .stepGetDetail "1. Get USDC on Solana" / "Buy it on an exchange you already use and withdraw it on the Solana network. USDC is all you need: network costs are a few cents and are paid out of it." steps.get: "Get USDC" / "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 Solana network. No account with us is needed."
    portfolioCopy.addMoney.stepSend, .stepSendDetail "2. Send it to your funding address" / "This first transfer is public and may link the sending address to you." steps.send: "Send it to your funding wallet" / "Copy the address below. In the other app choose USDC and the Solana network, and check the address before sending. This transfer is public." (the old detail is kept as portfolioCopy.receive.fundingNotice)
    portfolioCopy.addMoney.stepMove, .stepMoveDetail "3. Move USDC into a portfolio privately" / "Once it arrives, move it in through the private route so it is not linked to a portfolio on chain." steps.move: "Move it into a portfolio" / "When it arrives, choose a portfolio and tap Move to portfolio. A private move is not linked to your funding wallet in the public record. It costs 0.1% + $0.20. It usually arrives within a minute and can take a few."
    portfolioCopy.addMoney.depositAddress "Get your USDC deposit address" removed: the address is shown at once
    portfolioCopy.addMoney.footnote "Moving USDC privately has a service fee and may take time to arrive. Trades stay public." removed; costsLink: "What does it cost?"
    portfolioCopy.addMoney.copyDescribe, settingsCopy.funding.copyDescribe "Copy funding address" "Copy funding wallet address"
    settingsCopy.funding.advanced "Advanced: funding address" "Advanced: funding wallet address"
    fundingCopy.titlePrivate "Fund portfolio privately" "Move to portfolio"
    mobileFundingCopy.title "Add money privately" removed; the phone says fundingCopy.titlePrivate
    fundingCopy.titlePublic "Add SOL publicly" "Move SOL publicly"
    fundingCopy.stages.enqueue.title "Enqueue the private transfer" "Enqueue the private move"
    fundingCopy.noPrivateRoute "SOL has no private route. This is a direct, public transfer..." noPrivateMove: "SOL cannot be moved privately. This is a direct, public transfer..."
    fundingCopy.belowMinimum "A private transfer has to be at least X." "A private move has to be at least X."
    fundingCopy.depositLink "Get your deposit address" addressLink: "Show your funding wallet address"
    fundingCopy.privateCosts, mobileFundingCopy.costs "...The smallest transfer is X, and it usually arrives within seconds." "...The smallest private move is X. It usually arrives within a minute and can take a few."
    onboardingCopy.import.progress.lead, mobileOnboardingCopy.import.progress.lead "Keep this tab open. This usually takes a few seconds." (the phone: "Keep the app open...") "Keep this tab open. This can take up to a minute." (the phone: "Keep the app open...")
    fundingCopy.footerPrivate, mobileFundingCopy.footer "A private transfer breaks the onchain link..." "A private move breaks the onchain link..."
    the private review's confirm button (commonCopy.confirm) "Confirm" fundingCopy.confirmPrivate: "Move privately"
    mobileFundingCopy.chooseTitle "Add money to" "Choose a portfolio"
    mobileFundingCopy.cash "X USDC cash" removed; commonCopy.readyToInvest: "X USDC ready to invest"
    mobileFundingCopy.emptyDetail "Send USDC on Solana to your funding address first." "Add money to your funding wallet first."
    mobileFundingCopy.showAddress, .page.showAddress "Show my funding address" addMoney, page.addMoney: "Add money"
    mobileFundingCopy.stages[0].title "Sent to the private route" "Private move sent"
    mobileFundingCopy.page.empty "Nothing is waiting. Send USDC on Solana to your funding address to add money." "Nothing is waiting. Add money to your funding wallet first."
    mobileFundingCopy.page.move "Move to a portfolio" "Move to portfolio"
    pendingActionCopy.what.privateTransfer "a private transfer of X into Y" "a private move of X into Y"
    errorsCopy.funding.notPrivate "X cannot be funded privately. Use a supported token." "X cannot be moved privately. Use a supported token."
    errorsCopy.funding.privateNotStarted "The private transfer could not be started. Nothing left your funding wallet. Try again." "The private move could not be started. Nothing left your funding wallet. Try again."
    marketsCopy.lead "Tokenized stock trackers. Live display prices are indicative; the trade price appears at review." "Stock trackers. Prices shown are approximate. The final price is shown before you buy."
    marketsCopy.liveIndicative "Live indicative" approximatePrice: "Approximate price"
    marketsCopy.detail.indicative "Indicative" detail.approximate: "Approximate price"
    marketsCopy.detail.liveNote, .quoteNote, mobileMarketsCopy.detail.liveNote "Live indicative price. Your order quote is confirmed at review." / "Your order price comes from a live quote at review." / "Your order price is confirmed at review." detail.finalPrice: "The final price is shown before you buy."
    marketsCopy.detail.noChart "No verified 1W chart available." "Chart unavailable right now."
    marketsCopy.detail.historySource "Historical prices · Jupiter" "Historical prices"
    marketsCopy.detail.holdingValue "$X indicative value" "$X approximate value"
    marketsCopy.detail.issuerControl "The issuer can freeze, move or burn these tokens without your signature. Trades, amounts and timing are visible on chain." detail.issuerPowers, under "Read the risks": "The company that issues this tracker can freeze or remove it."; detail.publicTrades: "Trades, amounts and timing are visible on chain."
    marketsCopy.detail.dividends, mobileMarketsCopy.detail.dividends "Dividends are not paid out in cash. The issuer reinvests them by raising a multiplier on the token, so the balance shown here grows instead. Stock splits change the balance the same way." (the phone: "Splits change...") "Dividends are not paid out. The issuer reinvests them, so the balance shown here grows instead. Stock splits change the balance the same way." on both
    a tracker page's second line (marketsCopy.issuerLine) "NVDAx · " and the issuer's name detail.trackerLine: "NVIDIA tracker · NVDAx", then detail.follows: "Follows NVIDIA's share price. You do not own a share."
    tradeCopy.moreThanCash "More than your available cash." moreThanReady: "More than this portfolio has to invest."
    tradeCopy.priceAboveCash "The live price exceeds your available cash. Enter a smaller amount." priceAboveReady: "The live price is more than this portfolio has to invest. Enter a smaller amount."
    tradeCopy.noFundingFee "No private funding fee is part of this trade..." "No private move fee is part of this trade..."
    tradeCopy.publicTrade "...Private funding can reduce the link to your funding address; it does not hide this trade." "...A private move can reduce the link to your funding wallet; it does not hide this trade."
    tradeCopy.noCash, .noCashDetail "This portfolio has no cash to invest." noMoney: "No money in this portfolio yet"; noMoneyDetail unchanged in wording
    tradeCopy.result.soldFor "For $X, now in Investing's cash." "For $X, now in Investing, ready to invest."
    pieCopy.order.moreThanCash "More than your available cash." moreThanReady: "More than this pie has to invest."
    pieCopy.order.keepAsCash "Keep as cash" keepAsUsdc: "Keep as USDC"
    pieCopy.order.proceedsUnread "The sells went through, but the cash they returned could not be read..." "The sells went through, but the USDC they returned could not be read..."
    pieCopy.order.leftover "Stays as cash, too small to split" "Stays as USDC, too small to split"
    pieCopy.order.noCash "This pie has no cash to invest." noMoney: "No money in this pie yet"
    earnCopy.couldEarn "Your cash could earn X% a year at today's rate." "Your USDC could earn X% a year at today's rate."
    earnCopy.portfoliosLead "Move cash from each portfolio into Earn and back." "Move USDC from each portfolio into Earn and back."
    earnCopy.risks, .risksShort "...is not insured and is not cash available to trade..." "...is not insured and is not available to trade..."
    earnCopy.noCash "No portfolio has cash to deposit. Add money to a portfolio first." noMoney: "No portfolio has USDC to deposit. Move money into a portfolio first."
    earnCopy.noPortfolioDetail "Earn lends a portfolio's cash." "Earn lends a portfolio's USDC."
    earnCopy.cash "$X cash" ready: "$X ready to invest"
    earnCopy.terms.leavesCash, .totalLeaving, .arrives "Leaves Investing's cash", "Total leaving Investing's cash", "Arrives in Investing's cash" terms.leaves: "Leaves Investing"; "Total leaving Investing"; "Arrives in Investing"
    earnCopy.fromProceeds "...so no cash is needed first." "...so no USDC is needed first."
    earnCopy.landedBody.withdraw "Back in Investing's cash." "Back in Investing, ready to invest."
    networkCostCopy.needsCash "This portfolio needs at least X of cash to pay the network cost, and would have Y to spare." "This portfolio needs at least X to pay the network cost, and would have Y to spare."
    networkCostCopy.moveMoneyHere "Move money here" moveToPortfolio: "Move to portfolio"
    networkCostCopy.relayer.fromCash "from its cash" "from its USDC"
    sendCopy.explainer "...The cost is a few cents, taken from its cash as part of the send..." "...The cost is a few cents, taken from its USDC as part of the send..."
    sendCopy.cashPaysCost "X of this portfolio's cash pays the network cost, so Y is sent, not Z." "X from this portfolio pays the network cost, so Y is sent, not Z."
    mobileSendCopy.explainer "...a few cents taken from its cash..." "...a few cents taken from its balance..."
  • The privacy wording about NoirWire's own server overclaimed: it said the server "stores and logs nothing." It does keep a bare per-request record (that a request happened and whether it worked) and short-lived counters to stop overuse, and it issues an anonymous pass, unrelated to a wallet or an identity, that changes every day. Every sentence saying otherwise is reworded to say exactly that, on both platforms:

    Where Was Is
    onboardingCopy.welcome.trustMainnet / trustTestNetwork, mobileOnboardingCopy.welcome.trust (reworded, then removed from Welcome altogether: see the first-time wording below) "Your keys and recovery phrase stay ___. Network requests are relayed by NoirWire's server, which stores and logs nothing." "Your keys and recovery phrase stay ___. Network requests go through NoirWire's own server, which keeps only a basic record that a request was made, not your address or what's in it."
    portfolioCopy.observer.relayed, mobilePortfolioCopy.publicView.relayed "...Balance reads and trades are relayed by NoirWire's server, so the network provider, Jupiter and MagicBlock see this address but never your IP address. The relay stores and logs nothing; you have to trust it not to." "...Balance reads and trades go through NoirWire's own server, so the network provider, Jupiter and MagicBlock see this address but never your IP address. The server keeps only a basic record that a request was made, not your address or what's in it, but you have to trust it keeps nothing more."
    fundingCopy.footerPrivate, mobileFundingCopy.footer "...It does not see your IP address: the request is relayed by NoirWire's server, which stores and logs nothing. Privacy from the chain, not from the service." "...It does not see your IP address: the request goes through NoirWire's own server first, which keeps only a basic record that a request was made, not your address or what's in it. Privacy from the chain, not from the service."
    settingsCopy.protection.relay "...Network requests are relayed by NoirWire's server, so the network provider, Jupiter and MagicBlock never see your IP address. The relay stores and logs nothing, but it is a relay you have to trust not to log. Those providers still see the addresses themselves..." "...Network requests go through NoirWire's own server, so the network provider, Jupiter and MagicBlock never see your IP address. The server keeps only a basic record that a request happened and whether it worked, a short-lived count to stop overuse, and an anonymous pass that isn't tied to your name or wallet and changes every day. You have to trust it keeps nothing more. Those providers still see the addresses themselves..."
    mobileSettingsCopy.privacy.parties ("NoirWire's server") "It stores and logs nothing. You have to trust that; you cannot check it." / "It could link your funding wallet to a portfolio if it logged. It does not." "Keeps only a basic record that a request happened and whether it worked, a short-lived count to stop overuse, and an anonymous pass that isn't tied to your name or wallet and changes every day. You have to trust it keeps nothing more." / "It could link your funding wallet to a portfolio if it kept more than that. It does not."

    fundingCopy.footerPrivate and mobileFundingCopy.footer were the same sentence held twice (one spelled "on-chain", the other "onchain"); both now read from one privateMoveFooter constant in src/copy/funding.ts, which opens "A private move breaks..." (see the vocabulary below).