Skip to content

v0.5.3

Choose a tag to compare

@github-actions github-actions released this 04 Oct 17:30
· 22 commits to main since this release

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. 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.ts lost 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.ts had 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.ts lost 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 by tests/unit/keystore.test.ts, by every test over a record captured from a real app, and by one round trip in store.test.ts that checks 600,000 rounds were asked for.
  • CONTRIBUTING.md states the three rules a test is held to.