Repository navigation
v0.5.3
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:
homeViewtakes a sixth, required argument,freshness: { now, balances, prices }.MarketsStateandTrackerStatetake a requiredfreshness({ now, prices }, and{ now, prices, chart }) and no longer takeloading. PasslivePricesFreshness()from@noirwire/shared/infrastructureasprices. For balances and for a chart, keep aReadFreshness(start fromNEVER_READ) and move it on withrecordRead(previous, ok, Date.now())each time the read ends, whereokis the result the refresh already answers with (the web'sbalanceRefreshalready has it aswhole).nowis the screen's own clock: draw again when a refresh ends and at least everyREFRESH_INTERVAL_MS. Drawview.staleas the quiet notice and the waiting state whileview.loading; delete each app's own stale and first-load logic (BalanceStatus's own flag,refreshFailedon Home,firstLoadon Markets). Refresh balances everyREFRESH_INTERVAL_MSwhile Home is in view and when the app comes back into view: a read older thanSTALE_AFTER_MSshows the notice. - Cold open: run the network gate through
checkNetwork(() => connection.getGenesisHash(), expectedGenesisHash())from@noirwire/shared/application. It answers"ok" | "wrongNetwork" | "unreachable"withinNETWORK_CHECK_LIMIT_MS(8 s). The phone's owncheckNetworkhad 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.footeris gone. Renderview.costs.label("What does it cost?") as a disclosure that is closed at first (expandedByDefault: false) and showsview.costs.linesunder it when tapped. It must not navigate: the sheet and its address stay on screen. The Settings "Costs" page is unchanged. - Costs:
costsViewaccepts{ tradeFeeBps?: number | null }.settingsCopy.costs.tradeAtReviewis gone (tradeNoFeereplaces it). - A missing price:
TrackerRowView.noLivePriceis gone, andpriceis "Price unavailable right now." when there is no live price: draw the one string, with nothing beside it.TrackerView.pricewithout a live price is{ live: false, figure }(nonote).marketsCopy.atReview,marketsCopy.noLivePrice,marketsCopy.detail.priceUnavailableandportfolioCopy.home.atRevieware gone: usecommonCopy.priceUnavailable.marketsCopy.rowLabeltakes the price as a string. - Tracker page:
aboutis{ title, lines, retired }and its title is "About".about.notOfferedandabout.issuerDetailsare gone: the certificate line, the issuer's powers and where it is not offered are nowrisks.lines, and the issuer link's label isrisks.details. Draw all of them behindrisks.title("Read the risks").marketsCopy.detail.aboutAndRiskis gone. - Trade review:
tradeReviewView(...).risksis{ title, lines }. The web's "What you are buying" block (tradeCopy.tracker.what,.notOffered) moves behind "Read the risks": drawrisks.linesthere. - Earn screen:
EarnScreenView.venueand.mainnetOnlyare gone. Whenview.notHereis not null, show that one line at the top and nothing else of the screen's body:rate,deposit,withdrawandtotalare null androwsis empty.depositandtotalare now nullable. Drawview.risks.linesbehindview.risks.titleon the screen itself; the venue is named there and nowhere above it. The title isview.title("Earn") with no line under it. - Earn sheets:
earnSheetViewandearnReviewViewno longer takevenue.earnCopy.lead,.beforeDepositand.depositRiskare strings, not functions.earnCopy.earning(amount)takes one argument.earnCopy.notHereis gone (useearnScreenView(...).notHere, orearnCopy.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 whenisMainnet()is false.commonCopy.tradingUnavailableOnandpieCopy.order.tradingUnavailableare 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.urlis amailto:link andwebsite.action.urlanhttps: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, andearnCopy.earning(amount, venue)inPortfolioDetailare the call sites of the Earn changes above. -
MarketRow,DesktopMarkets,app/markets/page.tsxandDesktopHomereadatReviewandnoLivePricedirectly: drawcommonCopy.priceUnavailableonce in their place.
For the mobile app:
-
TrackerRowdrawsrow.noLivePriceandTrackerScreendrawsview.price.note,view.about.notOfferedandview.about.issuerDetails: see the tracker changes above.
Added
ReadFreshness,NEVER_READ,recordRead,freshnessOf,Freshness,REFRESH_INTERVAL_MS(30 s) andSTALE_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 thanSTALE_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_MSis nowREFRESH_INTERVAL_MS.freshnessView,FreshnessView,ScreenFreshness,HomeFreshnessandTrackerFreshnessin@noirwire/shared/presentation.HomeView.staleand.loading;MarketsView.loading;TrackerView.loading. A chart is read once for a range, so only its own failure makes it stale.checkNetwork,NetworkCheckandNETWORK_CHECK_LIMIT_MS(8 s) in@noirwire/shared/application.aboutView(),discardPromptView()andCostsStatein@noirwire/shared/presentation.AddMoneyView.costs;EarnScreenView.notHereand.risks;TrackerView.risks.details; the trade review'srisks.- 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. You do not need an account with us." |
commonCopy.priceUnavailable |
"Price unavailable" | "Price unavailable right now." |
marketsCopy.atReview, portfolioCopy.home.atReview |
"At review" | removed: commonCopy.priceUnavailable |
marketsCopy.noLivePrice |
"No live price" | removed: commonCopy.priceUnavailable |
marketsCopy.detail.priceUnavailable |
"Current price unavailable" | removed: commonCopy.priceUnavailable |
marketsCopy.rowLabel (spoken) |
"..., no live price" | "..., Price unavailable right now" |
onboardingCopy.import.title |
"Import an existing wallet." | "Restore your wallet" |
onboardingCopy.import.importedTitle |
"Wallet imported." | "Wallet restored" |
onboardingCopy.import.reunitedTitle |
"Wallet reunited with its funds." | "Wallet restored" |
onboardingCopy.import.submit |
"Import wallet" | "Restore wallet" |
onboardingCopy.import.progress.title |
"Importing your wallet" | "Restoring your wallet" |
onboardingCopy.import.networkFailed, .progress.failed (and mobile) |
"We couldn't finish importing your wallet. ..." | "We couldn't finish restoring your wallet. ..." |
onboardingCopy.import.notNow |
"... so nothing was imported. ..." | "... so nothing was restored. ..." |
mobileOnboardingCopy.import.offline |
"... Go back online to import your wallet." | "... Go back online to restore your wallet." |
walletCopy.unlock.forgotten, mobileWalletCopy.unlock.forgotten, walletCopy.store.damaged (and mobile), walletCopy.store.addressMismatch |
"... Reset [it] and import it again from your recovery phrase." | "... Reset [it] and restore it from your recovery phrase." |
mobileSettingsCopy.cleanupFailing |
"... Reset the wallet and import it again if this stays." | "... Reset the wallet and restore it again if this stays." |
activityCopy.importedNote (and mobile) |
"This wallet was imported {where}. Activity from before the import, made on another device, ..." | "This wallet was restored {where}. Activity from before that, made on another device, ..." |
onboardingCopy.import.testNetworkOnly |
"Devnet SOL only. " | "Test network SOL only. " |
onboardingCopy.import.twoSets |
"... you choose when the chain cannot tell which one you mean." | "... you choose when it is not clear which one you mean." |
onboardingCopy.import.chooseIntro |
"Neither set shows anything onchain yet. ..." | "Neither set has been used yet. ..." |
onboardingCopy.import.nothingFound |
"Nothing found onchain" | "Nothing found yet" |
mobileOnboardingCopy.source.nothingFound |
"Nothing found on chain yet" | "Nothing found yet" |
onboardingCopy.import.importedIntro |
"Nothing was found onchain for these addresses yet. ..." | "Nothing was found for these addresses yet. ..." |
onboardingCopy.import.discovered, mobileOnboardingCopy.result.found |
"Found N portfolios this phrase already had onchain." / "... on chain." | "Found N portfolios this phrase already had." |
mobileOnboardingCopy.result.foundBalances |
"Found token balances this phrase already had on chain." | "Found token balances this phrase already had." |
onboardingCopy.password.finish |
"Encrypt and finish" | "Save and finish" |
onboardingCopy.password.encrypting |
"Encrypting..." | "Saving..." |
onboardingCopy.password.encryptFailed |
"We couldn't encrypt your wallet, so nothing was saved. Try again." | "We couldn't save your wallet. Nothing was saved. Try again." |
settingsCopy.password.reEncrypting |
"Re-encrypting..." | "Saving..." |
walletCopy.newPassword.suggest |
"Suggest a passphrase" | "Suggest a password" |
The strength check's reason (assessPasswordWith) |
"... or tap Suggest a passphrase for a strong one." | "... or tap Suggest a password for a strong one." |
settingsCopy.recovery.lead |
"The phrase every portfolio in this wallet is derived from. ..." | "The phrase every portfolio in this wallet comes from. ..." |
settingsCopy.recovery.footnote |
"... controls every portfolio this wallet derives, on any device. ..." | "... controls every portfolio that comes from them, on any device. ..." |
portfolioCopy.receive.lead |
"{portfolio}'s own address on {network}, derived from your recovery phrase." | "{portfolio}'s own address on {network}. It comes from your recovery phrase." |
portfolioCopy.receive.testNetwork |
" It holds devnet assets only, so never send mainnet funds to it." | " It holds test network assets only, so never send main network funds to it." |
settingsCopy.protection.description |
"What others can see, on chain and off" | "What others can see" |
settingsCopy.protection.phraseTestNetwork |
"... Do not send mainnet funds here, ..." | "... Do not send main network funds here, ..." |
settingsCopy.risks.paragraphs (Earn) |
"USDC in Earn is lent out. The rate changes, ..." | "USDC in Earn is lent out through Jupiter Lend. The rate changes, ..." |
appCopy.networkBanner |
"Test network · never send mainnet funds" | "Test network · never send main network funds" |
commonCopy.tradingUnavailableOn |
"Live trading runs on Solana mainnet. You can explore trackers on {network}." | "Live trading runs on the main network. You can explore trackers here." |
errorsCopy.trade.mainnetOnly |
"Live trading runs on mainnet." | "Live trading runs on the main network." |
errorsCopy.earn.mainnetOnly |
"Earning runs on Solana mainnet." | "Earning runs on the main network." |
earnCopy.mainnetOnly |
"Earn runs on Solana mainnet." | "Earn runs on the main network." |
earnCopy.notHere |
"Earn runs on Solana mainnet. Switch from {network} to lend or withdraw." | removed: earnCopy.mainnetOnly |
pieCopy.order.tradingUnavailable |
"The pie is saved. Live tracker trading runs on mainnet; switch from {network} to invest it." | "The pie is saved. Live trading runs on the main network." |
networkLabel() |
"Solana mainnet" / "Solana devnet" | "Solana" / "Solana test network" |
earnCopy.deposit |
"Deposit" | "Add to Earn" |
earnCopy.sheetTitle |
"Deposit · {portfolio}" | "Add to Earn · {portfolio}" |
earnCopy.confirm |
"Confirm deposit" | "Add to Earn" |
earnCopy.confirmAmount.deposit |
"Deposit {amount}" | "Add {amount} to Earn" |
earnCopy.progress.deposit |
"Depositing {amount}" | "Adding {amount} to Earn" |
earnCopy.landed.deposit |
"Deposited {amount}" | "Added {amount} to Earn" |
earnCopy.earnedSinceDeposit |
"{amount} earned since deposit" | "{amount} earned since you added it" |
earnCopy.noMoney |
"No portfolio has USDC to deposit. Move money into a portfolio first." | "Move money into a portfolio first, then add it to Earn." |
earnCopy.beforeDepositTitle |
"Before you deposit" | "Before you add to Earn" |
earnCopy.beforeDeposit |
"USDC is lent through {venue}. The rate changes. ..." | "USDC in Earn is lent out. The rate changes. ..." |
earnCopy.depositRisk |
"USDC is lent through {venue}. It is not a bank deposit, and withdrawals can be delayed." | "USDC in Earn is lent out. It is not a bank deposit, and withdrawals can be delayed." |
earnCopy.lead |
"Lend USDC from a portfolio through {venue}. The rate is variable." | "Lend USDC from a portfolio. The rate is variable." |
earnCopy.earning |
"{amount} earning through {venue}" | "{amount} in Earn" |
earnCopy.venueLine (new, under "Read the risks") |
"USDC in Earn is lent through {venue}." | |
portfolioCopy.earnPromo.eyebrow |
"Earn · Jupiter Lend" | "Earn" |
pendingActionCopy.what.earn |
"an Earn deposit" | "adding to Earn" |
earnCopy.steps[0], sendCopy.steps[1] |
"Sending on chain" | "Sending" |
portfolioCopy.home.portfolioLine.holdings |
"{ready} to invest · N holdings" | "{ready} ready to invest · N holdings" |
portfolioCopy.home.portfolioLine.pie |
"Pie · N trackers · {ready} to invest" | "Pie · N trackers · {ready} ready to invest" |
portfolioCopy.home.togetherExplained (and mobile) |
"... Nothing on chain ties your portfolios to each other ..." | "... Nothing public ties your portfolios to each other ..." |
portfolioCopy.publicView.notFullHistory (and mobile) |
"... is public on chain, with its amount and time, ..." | "... is public, with its amount and time, ..." |
portfolioCopy.observer.noLabel |
"No owner label onchain" | "No owner label in public" |
portfolioCopy.observer.publicTitle |
"Public onchain" | "Public" |
portfolioCopy.observer.privateTitle |
"Not written onchain" | "Not in the public record" |
mobilePortfolioCopy.publicView.notOnChainTitle |
"Not written on chain" | "Not in the public record" |
activityCopy.detail.recorded (and mobile) |
"... The transfer itself is public on chain." | "... The transfer itself is public." |
fundingCopy.footerPrivate, mobileFundingCopy.footer |
"A private move breaks the onchain link ... Privacy from the chain, not from the service." | "A private move breaks the public link ... Privacy from the public record, not from the service." |
fundingCopy.stages.arrive.detail |
"Confirmed by reading this portfolio's real onchain balance." | "Confirmed by reading this portfolio's real balance." |
fundingCopy.noPrivateMove |
"... and it links the two addresses onchain. ..." | "... and it links the two addresses publicly. ..." |
fundingCopy.arrived |
"... read back from its real onchain balance." | "... read back from its real balance." |
fundingCopy.footerPublic |
"This is an ordinary, fully public onchain transfer." | "This is an ordinary, fully public transfer." |
errorsCopy.send.moreThanOnchain |
"More than this portfolio holds onchain." | "More than this portfolio holds." |
marketsCopy.detail.publicTrades |
"Trades, amounts and timing are visible on chain." | "Trades, amounts and timing are public." |
marketsCopy.detail.dividends |
"Dividends are not paid out. The issuer reinvests them, so ..." | "Dividends are not paid out. They are reinvested, so ..." |
marketsCopy.detail.aboutAndRisk |
"About and risk" | removed: marketsCopy.detail.about ("About") |
tradeCopy.trackerLine |
"{symbol} is a tracker, not a share, and its issuer keeps control over it." | "{symbol} is a tracker, not a share." |
pieCopy.order.trackersLine |
"These are trackers, not shares, and their issuer keeps control over them." | "These are trackers, not shares." |
commonCopy.discardBody (new) |
"What you typed will be lost." | |
| Refusals from the chain clients | "More than this address holds onchain.", "The transfer failed on chain.", "The transaction failed on chain.", "... would name one of your portfolios on chain next to your funding wallet. Not signed." | "More than this address holds.", "The transfer failed.", "The transaction failed.", "... would name one of your portfolios publicly next to your funding wallet. Not signed." |
The Privacy and Risks pages keep their substance: they still say what is public on chain, what the issuer can do and that Earn is not insured, and a test now checks that each of those risks is still stated.
tests/unit/copyVariants.test.ts gained PLAIN_WORDS: it refuses "on chain" and "onchain", "derived" and "derives", "passphrase", "Encrypt and finish", "Deposit" (but for "bank deposit"), "At review" and "No live price" in the everyday copy; "import" and the clusters' own names in every string; and "Jupiter", "xStocks", "issuer" and "certificate" in the main path, which is the everyday copy without the keys that sit behind "Read the risks" and without a portfolio's own privacy panel.
Tests
- Rules are tested, prose is not.
tests/unit/copyVariants.test.tslost 11 cases that only pinned wording (about 125 literal strings) and gained 10 rule checks: a phone variant is the web's string with only the platform's words changed, a failure says what it means for the money, the retired words stay out, the Risks and Privacy pages still state each risk.tests/unit/presentation/firstTime.test.tshad 4 wording cases merged into the rule and behaviour checks beside them, and its literals replaced by the copy they read.src/design/mobile.test.tslost 4 cases that restated constants; the touch-target and focus checks stay. - The store, key-bits and pending-action suites derive a key in one round instead of 600,000 through
tests/unit/support/fastKdf.ts, at the WebCrypto boundary, with no change to the package's code. The real derivation is still run bytests/unit/keystore.test.ts, by every test over a record captured from a real app, and by one round trip instore.test.tsthat checks 600,000 rounds were asked for. CONTRIBUTING.mdstates the three rules a test is held to.