Skip to content

Bramble mobile: iOS and Android wallets on a Rust krusty-kms core - #450

Draft
teddyjfpender wants to merge 170 commits into
mainfrom
feature/mobile-wallet
Draft

teddyjfpender wants to merge 170 commits into
mainfrom
feature/mobile-wallet

Conversation

@teddyjfpender

@teddyjfpender teddyjfpender commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Refs #453

Bramble for iOS and Android. The native apps sit on one Rust core, bramble-core (mobile/). It is built on the published krusty-kms crates, pinned =0.11.0: the same release the extension loads as WASM. The core owns key custody, the vault, and all Starknet protocol logic. It is sans-IO: Swift and Kotlin render UI and move bytes, and never hold a private key.

mobile/SCOPE.md covers the architecture, security posture, phases, and known limits. Each workstream has a design doc under mobile/docs/.

Draft, paused. All workstreams are merged. The open items are in the TODO list below.

What's in it

Workstream Status Highlights
Core + first apps ✅ UniFFI core, extension-compatible vault envelope (PBKDF2-SHA-256 600k → AES-256-GCM), sans-IO RPC. Parity fixtures prove the core derives the same addresses and signatures as the extension, and opens its vaults.
Design system ✅ Everything is generated from the extension's TypeScript, never restated (bun run mobile:assets): tokens, derived colours, elevation, metrics, 59 icons, wordmark, token logos, networks. A drift test lives in bun run check. Dark by default, flat fields.
vault-security ✅ Opt-in Face ID / fingerprint unlock (hardware-bound key wraps the vault DEK; the passphrase is always the way in). Persisted backoff on a monotonic clock, so moving the date forward doesn't skip it. Idle lock follows the extension's renewal rule. Screenshot and recording protection.
design-mobile ✅ Screen-by-screen parity with the UI Lab in both themes. Dynamic Type, 44pt targets, haptics, springs, reduced motion, swipe-back, SE / Pro Max layouts. Feedback components. Snapshot tests on offline fixtures.
transactions ✅ ERC-20 send, fee estimation with the extension's margins and caps, OZ deploy-before-first-send, invoke v3 hash via Krusty (parity-tested against the extension). A pending record is written before any write leaves the device. Plans are invalidated by any lock.
android-parity ✅ Compose port of every screen, one hardened secret field, FLAG_SECURE, no-backup vault.
accounts ✅ The extension's naming, 20-index discovery (OZ / Argent / Braavos), recovery screens, drawer groups, address copy, signing per account record (one vault migration chain v1→v3). Ready (Argent) sends are refused until an extension-generated signature fixture exists; Braavos is view-only.
nostr-multisig ✅ Nostr coordination key in the core, Bramble contact card, joining an Argent multisig, relay sync with the extension's protections.
portfolio-activity ✅ Fiat prices and 24h change, token management, activity feed from Starkscan / RPC with explorer links.
privacy ✅ Read-only STRK20 private balances: viewing key derived in the core, bounded and validated discovery, OHTTP refusal. Verified live on mainnet.

Deferred, and why

  • Shield / private transfer / unshield: krusty-kms 0.11.0 has no privacy client, and we don't re-implement proving. This needs an upstream Krusty change.
  • Multisig deploy and intent signing: Krusty 0.11.0 exports no Stark signature verification, which the extension requires before importing co-signer intents.
  • Android follow-ups: an instrumented send-flow test, and the token management screens (add by contract, discover, hide).

Documented decisions

  • Viewing key on the wire: the STRK20 viewing key travels inside the discovery request that the host transports. It sits in a wiped buffer, goes over HTTPS only to the configured service, and is never persisted or shown. This matches what the extension sends, and the owner approved it as a documented exemption from the "never crosses FFI" rule.
  • Flat input fields: fields have no bottom edge, a deliberate departure from the extension's filled-field edge.

Test plan

  • bun run check: types, lint, standards, pins, and all tests, including the mobile drift and parity conformance tests.
  • cd mobile/core && cargo fmt --check && cargo clippy --all-targets --locked && cargo test --locked
  • Full iOS scheme (unit + UI + snapshot) green at each merge through design-mobile and transactions.
  • Opt-in mainnet runs against the owner's wallet: derivation, balances, and private-balance discovery (10 private STRK found). They need explicit env vars and refuse to run under CI.

TODO

  • Lint: bun run mobile:lint passes: SwiftLint --strict, ktlint and detekt report zero findings, there are no suppressions, and the pinned tools are checksum-verified.
  • iOS UI tests: the full scheme passes at 7df68dda (90 unit tests with 2 opt-in live tests skipped, and 25 UI tests, 0 failures). Stale tests are updated to the merged app, and portfolio balance reads in UI tests now go to the offline fixture node instead of live Sepolia.
  • Android: it builds (./gradlew :app:assembleDebug), and 40 JVM tests plus 36 instrumented tests pass on an API 35 emulator. The opt-in live test is skipped.
  • Android gaps: the deploy notice and Deploy account flow, the activity list from the core's store with explorer links, token detail with the private-balance section, and the real deployment state passed to the privacy gates.
  • CI: pr-guards passes. In .github/workflows/mobile.yml, core, lint and android were green on the previous run. The ios job's only failures were two SwiftLint analyzer findings, which 7df68dda fixes; the local analyzer re-run was cut short, so the CI run on 7df68dda is the confirmation.
  • Ready (Argent) sends: Argent 0.4.0 accounts, and 0.3.x accounts once upgraded, send and deploy with the extension's argent-signer-array authorization. They are proven bit-for-bit against the extension's own signing path: 7 scenarios and 9 refusals.
  • Android follow-ups: an instrumented send-flow test, and the token management screens (add by contract, discover, hide). OnboardingFlowTest.importMainnetThenLockUnlock was flaky once, most likely a slow mainnet response.
  • Upstream Krusty: a privacy client (shield / private transfer / unshield) and Stark signature verification (multisig deploy and co-signer intents).
  • Transactions: The slow L1-finality observation tier (15 min for up to 7 days) and the retired notices are in, mirroring tx-observation.ts.
  • Backup-pending flow: Vault v4 adds a backupPending flag. After unlock, the app resumes at Save phrase and then Confirm backup.
  • Design follow-ups: The screenshot warning now uses the extension's untoned toast, and toasts sit above the foot action.

To try it: mobile/scripts/build-core-ios.sh debug && cd mobile/ios && xcodegen generate && open Bramble.xcodeproj (see mobile/README.md).

🤖 Generated with Claude Code

adrienlacombe and others added 30 commits September 22, 2026 17:26
Bramble registered with autoSetup off, so activation published the
viewing-key commitment but never opened the account's channel to itself.
The pinned SDK adds that channel implicitly on the first batch that
needs it and warns (USER_LINKAGE) when a single batch opens more than
one channel; the wallet refuses on any warning. So an account whose
first shield named another wallet had to open two channels at once and
always failed, before proving, with the bare "strk20 privacy leak".
Confirmed on mainnet on 2026-09-22 against the gateway logs:
registration at 09:10 UTC, then two shield attempts that ran channel
discovery and never reached the prover.

Turn autoSetup on for the registration builder so the self channel
opens alongside the viewing-key commitment, say so in the review copy,
the dapp approval summary, and the docs, and pin the option with a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The multicall, invoke, Ledger, recovery, and NFT checks now fail when the
behavior they describe is missing. A stuck onboarding view transition no
longer covers the page, which was timing those checks out in headless Chromium.

Fixes #410

Co-authored-by: Cursor <cursoragent@cursor.com>
UI Lab typechecks this module with the DOM timer, which returns a number, while the annotated handle was Node's Timeout. Use the same setTimeout the annotation names.

Co-authored-by: Cursor <cursoragent@cursor.com>
A session of UI work from user feedback. It shares one thread: the wallet asked
people to leave a screen, or to read a control that did not look like one.

**One token dropdown, opened in place.** Choosing a token left the screen twice
over — Send pushed a whole stage, Swap opened a modal sheet — and the two lists
drifted. Both now open the same dropdown under their trigger: tickers in
labelled groups, what the account holds first, then the rest of AVNU's verified
catalog, with a search on the catalog group and a "Manage tokens" row for a
token it does not carry. Choosing an untracked entry still goes through the
wallet's add-token path, which reads the token's facts from the CONTRACT, so
the catalog stays a directory and never a source of truth. Send's asset stage
and Swap's sheet, chips and 59 lines of sheet styling are gone.

**The AVNU list is reachable from the tokens screen** as its own screen, where
each entry offers Add, Hide or Show against the wallet's own registry.

**Send's recipient screen** carries the step rail (Recipient, Amount, Confirm),
stacks the address book above My accounts instead of tabbing between them, and
offers "Save address" for an address that is neither yours nor saved — which
hands it to the address book, prefilled.

**The public/private control is a switch**, not a word that renamed itself to
the state it was already in, which is what made it read as static text. The
switch is a shared ui-kit control, so the address book's new "Pro" toggle is the
same component.

**The address book asks for two things**: a label and an address, with
everything else behind that Pro toggle. It also canonicalises the address before
saving — the background takes lowercase hex only, so an address pasted as
0x04F2… was refused as a malformed request even though Send had just accepted
that exact text.

Also: the three-dots menu drops "Wallet settings", which the dock already
carries, and the empty address book no longer states that it is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI Lab checks failed on four stories after the token-picker rework:

- the production-view coverage contract had no scenario for the new
  token-catalog view;
- the address book story asserted the wallet card and key fields as
  visible, but they now wait behind the form's Pro switch, and the lab
  rendered the form without its binding, so the switch did nothing;
- the swap scenario still picked ETH through the old picker's
  data-swap-pick-address attribute, so the pick silently stopped and the
  fixed fixture quote rendered with its labels swapped, which the frozen
  review story caught as "19,260 ETH";
- the swap keyboard contract looked for the old modal sheet and typed
  Escape at the host document rather than into the frame.

Add the token-catalog checkpoint and story, bind the production address
book form in its checkpoint and make the story open Pro first, pick ETH
through the dropdown option, and rewrite the keyboard contract for the
dropdown: focus enters the panel on open, Escape closes it and returns
focus to its trigger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four review findings on the token-picker rework:

- A token picked from the dropdown wrote the row's public figures into
  the amount card without re-measuring the source bucket, because the
  bucket binding only followed clicks on the hidden fact rows. Follow
  dropdown options too, so a private send keeps its private balance
  after the asset changes.
- The AVNU catalog kept the first screen's paint and applied an in-flight
  result regardless of network, so a shell refresh painted a detached
  element and a network switch could pin another network's list. Paint
  the live screen, drop a result from a superseded request or the
  network the user left, and clear the loading flag on a network change.
- While the list loaded, an empty catalog read as a search miss. An
  empty list is loading or failed, and the status line says which.
- Every dropdown binding added an outside-click listener to the document
  and never removed it; Swap rebinds on every paint. Keep one listener
  per panel id per document, replace it on rebind, and let a listener
  whose panel left the document remove itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A shell refresh or a network change while AVNU's list was in flight painted
the first panel, or pinned the previous network's tokens, because the fetch
never dropped its loading flag or its captured root.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
Catalog search compared the ticker to itself and left hits in list order, so
a token name or a pasted contract reported nothing and an exact symbol did
not lead its prefixes.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
The Send address was cleared on the first bind, so the next shell paint
rebuilt an empty form and dropped the address and any label already typed.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
A ticker letter sits inside nearly every contract, so substring matching on
the address kept the whole catalog visible. A pasted contract still matches
with different padding, and a 0x prefix still narrows to that start.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
Receiving needs one thing: the address, as a QR and as text. The screen asked
a user to read past a payload switch, a card explainer, two copy buttons and a
wallet-identity disclosure to find it, and the copy control sat apart from the
address it copied.

The address IS the copy control now: one wide row carrying the exact canonical
string, with a clipboard mark on the right saying so. The QR sits above it and
the network below, because sending on the wrong network loses funds.

Everything else — the Address / contact-card switch, the card explainer, Copy
wallet card, and the signer and coordination keys — moves behind a Pro switch
that starts off, the same control the address book uses. The fields stay in the
DOM, so the card's derivation and its failure path are unchanged. A multisig
receive screen has no card or keys, so it has no switch and keeps its hint.

Receive takes its own stylesheet with the rules it already owned: the send
layer was at the line cap and could not hold the new ones.

Refs #403.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…istive tech

Two review findings on the Receive rework, plus the lab story it broke:

- Closing Pro hid the payload switch but left the QR on whatever payload
  was last chosen, so a user who picked the contact card and then turned
  Pro off saw a card QR under "Your Starknet address" with no control to
  change it, and other wallets cannot scan that payload. Closing Pro now
  puts the address back on the QR.
- The address lived only inside the copy button, whose aria-label was the
  copy prompt, so assistive tech heard "Copy address" and never the
  address. The address element is now the button's description.
- The receive story asserted the old identity disclosure; it now opens
  Pro and asserts the switch, the keys, and the card behind it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Add a new account" listed "Restore a Ready Ledger account" beside the routes
that create one. Bringing over an account from Ready is importing something the
user already owns, which is exactly what the Import screen is for — it sat one
level too high, and made the create list read as five unrelated options.

It moves in beside the recovery phrase and the private key, renamed to "Ready
Ledger account" now that its screen already says these are existing accounts.
The plain Ledger route stays on the chooser above, because it also creates
accounts on Bramble's own derivation path.

Routing is unchanged: both screens bind the same tile handler, so the tile
reaches the same connect gesture from its new home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A first send from an undeployed account is two transactions, and the review
quoted them as two lines: "Estimated transaction fee" and "Estimated account
deployment fee". The first read as the cost of sending, so the figure a user
acted on was the smaller half of what left their account.

The review now leads with the total and names the deployment share under it.
The background already computed that total for the affordability check; it is
what the user pays, so it is what the review shows. The account being created
is still stated — hiding it would trade one wrong impression for another.

A Ledger signs both transactions, so the review says the device will ask twice
rather than leaving the second prompt as a surprise after the confirm press.
That cannot change without a relayer, and Ready does not use one either.

Checked on Mainnet against a fresh Ready account: DEPLOY_ACCOUNT and INVOKE,
both sent by the account, 0.028335 + 0.031929 STRK, same block. Ready's
advantage was never a bundle — it was one confirmation and one figure.

Refs #381.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home flagged an account that is not on chain yet with a danger-coloured chip
and an alert icon, as though something had gone wrong. Nothing has: the account
works, it receives funds, and its first send creates it — which the review now
quotes as one fee.

The chip keeps the shortcut for a user who would rather deploy first, in the
neutral voice, and its tooltip says what actually happens instead of implying
a blocked account.

Refs #381.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file is at the 500-line cap on its own, and gains lines from both this
branch and #415; the close cases are a coherent unit and move whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home flagged an account that is not on chain yet with a chip reading "Deploy
account", or "Fund with STRK" when it could not pay. Neither is a thing the
user has to do any more: the first send creates the account and the review
quotes both fees as one figure, so the chip announced a chore that no longer
exists.

It is gone from Home. Deploying first is still a choice worth keeping, so it
moves to Settings → Security & permissions, under "This account", with a line
saying what happens if you do nothing. The dedicated deployment flow behind it
is unchanged.

The Shield tile keeps its own "Fund with STRK" state: shielding really does
need STRK, whatever the account's deployment state.

Refs #381.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rity hub

Two review findings on the one-fee change:

- The NFT review built the same DTO as the ERC-20 review and fed the same
  fee renderer, but never set `signsTwice`, so a Ledger first-send of an
  NFT hid that the device prompts twice. Set it from the same rule, type
  the field on the shared review response, and cover an undeployed
  Ledger account in the NFT message tests.
- The Deploy account row moved to Security & permissions, but nothing in
  the wallet linked to that screen, so removing the Home chip left the
  deploy-first journey unreachable. The Settings hub now links to
  Security & permissions.

The review response types move to runtime-review-types.ts to keep the
runtime types file under the 500-line cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sumFeeEstimates fails closed on unlike units. The review was still quoting
the transaction fee as the estimated total and calling the deployment fee a
share of it.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
The failure copy was on screen, but the reviewed key stayed set because
neither estimate was flagged failed. Retry then submitted the send, and
Confirm stayed enabled. That review is now treated as blocked, same as a
missing component, on Send and on NFT transfer.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
The drawer listed every account in one flat list with a per-row folder
picker, so the folder an account was in was something you opened a panel
to discover rather than something you could see. Accounts are now listed
under the folder they belong to, and moved by dragging a row onto another
folder.

Two folders are structural and exist in every wallet: "My accounts",
where an account lives until it is filed somewhere else, and "Archived
accounts", which starts empty, always sorts last, and starts collapsed.
An account in no folder is shown under the home folder, so a new account
appears there without anything being written; filing it elsewhere is
what writes. Neither can be deleted — only renamed and re-iconed, on the
manage view where the rest of folder management already lives.

Both default folders are seeded on the write path as well as the read
path. The pure setters no-op on a group id they cannot find, so without
that a wallet which had never stored a group would accept a drop onto
"My accounts" and silently do nothing. Seeding the written list too also
keeps a newly created folder stored in display order rather than after
the archive.

"Add folder" sits under the last folder and creates one in place, so the
whole flow stays in the drawer. Drag and drop is the only way to move an
account; moving one by keyboard is the gap left on this screen, noted in
the binding module.

The filter chips, the per-row quick picker and the assign panels they
opened are gone, along with the transient state that survived repaints
for them — TransientGroupState is now just the manage view's expanded
group. The group writes moved out of preferences.ts, which the split put
back under the file-size cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drawer always listed My accounts and Archived accounts, but the manage
screen read stored groups raw, so a wallet that had never saved a folder
could not rename them. Creating a folder also opened the archive, because
the client treated the last stored group as the one just created after the
write path started sorting the archive last.

Default folders are seeded for that screen the same way as the drawer, they
have no delete control, and the created folder is the last group that is
not a default.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
A balance repaint emptied the inline folder name and pulled focus back onto
that field. The sheet-dismiss gesture also captured pointer moves that start
on an account row, so a filing drag closed the drawer.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
A drop into a collapsed folder hid the account that just moved. The manage
view also counted only stored ids, so an account the drawer shows under My
accounts looked absent, and clearing that row did not move it.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
The file-standards gate scans every module outside the vault boundary for
secret-material words, and "seed" is one of them; the doc comment on
displayedAccountGroup used it in the ordinary sense of installing
default groups. Say that instead so the gate reads the model as what it
is: non-secret account grouping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dragleave on the folder section also fires when the pointer enters a heading
or row inside it, so the highlight flashed off across every child.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
A deleted folder's id stayed in the collapsed set, so a later folder that
reused the same slug opened already closed.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
Send listed every account of the user's own in one flat block, so an
account filed into a folder was found in a different place depending on
which screen you were looking at. The recipient list now groups accounts
with the same split the drawer uses: My accounts, the user's folders,
then the archive, with shared accounts after them.

The archive renders minimized, like the drawer. A search reaches into it
anyway: an address the user is looking for has to be findable wherever
it is filed, so the stage would otherwise answer "No matching recipient"
for an account the wallet holds. Clearing the search closes it again. A
folder with nothing to show drops out while searching rather than
leaving a heading over empty space.

Folder state here is DOM-local. The recipient stage is toggled rather
than re-rendered, so a collapse survives without threading state through
the entrypoint the way the drawer has to.

Also lower-cases avnu in every string the wallet shows: the swap header
and route row, the token catalog screen, the "Pick tokens from the avnu
list" entry, the swap build refusals, and the activity summary a swap
records. The summary is read by the activity formatter, whose pair regex
keys off "swap ... for ..." rather than the provider name, and no dedup
guard matches it — only the STRK20 registration summary is matched that
way. Rows already stored keep their original casing.

Identifiers such as AVNU_BASE_URLS are unchanged; this is display copy.

The recipient stage moved to its own module to stay under the file cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collapsed archive with no accounts still showed "No accounts in this folder."
The folder mark stays chevronRight and rotates while the folder is open, so a
toggle cannot leave the glyph pointing the way it did at render.

Co-authored-by: Adrien Lacombe <adrienlacombe@users.noreply.github.com>
First journey of the wording review. The 49 user-facing strings in the
settings journey were reviewed string by string against the new
docs/COPY_STYLE.md; 19 changed and the rest were confirmed as they are.

The feature was called "folder" in the account drawer and "group" in
settings — the same thing under two names on adjacent screens. The UI now
says folder throughout. The code keeps `group` (AccountGroups,
DEFAULT_GROUP_IDS, the stored preferences.groups key) because renaming a
persisted key needs a vault migration, which this is not; the doc comments
that quote a UI label were updated so they stay accurate.

"Tap to show" was a bug, not a register: this is a desktop extension driven
by a click, and the reveal result line read "Revealed. Tap to show, then
write it down offline." — announcing the secret was revealed and then
telling you to reveal it.

The long disclosure paragraphs were tightened without dropping disclosure.
Each was rewritten from its list of facts and checked back against it, so
the explorer, telemetry, clipboard, Ledger-recovery and backup-restore
copy still say everything they said before in fewer words. "Helps us
understand feature usage" was the only first-person plural in the
extension and is now "Helps Bramble".

docs/COPY_STYLE.md records the rules, most of which were already being
followed, plus the terminology table and the two categories excluded from
wording changes: activity summaries, which dedup guards match on, and the
approval-review sentences, which are what a user reads before signing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adrienlacombe and others added 2 commits October 1, 2026 00:32
The extension formats dates through Intl, and ICU builds disagree on one
en-GB month: Bun on the Linux CI runner writes "21 Sept", Bun on macOS
"21 Sep". The same code therefore produced a different fixture on each,
and the drift test failed on CI against a fixture made on a Mac. The
generator now pins the core's spelling, so the fixture depends only on
the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
outcomeReceiptFor treated any answer from the chain as success. A
ConfirmationOutcome is `succeeded` or `failed`, and rejected and reverted
both arrive as `failed` with a message, so a deployment or NFT transfer
the chain refused still turned its receipt into "Account deployed" or
"Transfer complete" over a hash that did not land.

The receipt now has Swap's three endings. Succeeded: the confirmed copy.
Failed: "Deployment failed" / "Transfer failed" over the chain's reason,
with the failure mark and tone. Out of patience: "submitted" and "Not
confirmed yet", as before. The receipt opens before the chain answers, so
it ships both marks and the paint shows one; the failure tone keeps the
single shrinkable column so the hash still ellipsizes.

As in Send and Swap, the toast is now the chain's answer, in its tone.
The handoff toast ("Deployment submitted", "Transfer submitted") is gone:
it said what the receipt heading says at the same moment.

The rejected message read "No transfer was completed", which is false
under "Deployment failed". It is shared by all four journeys, so it now
says what is true of each: "Nothing changed."

The NFT send suite went over the size limit with the receipt tests, so
its gallery-binding tests move, unchanged, to their own file, and the
send stub now returns a full-length hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adrienlacombe and others added 3 commits October 1, 2026 06:59
The wasm-bindgen `--target web` init only returns early once an instance
has finished loading. Two calls that overlap both instantiate the module,
and the one that finishes last replaces the instance under every object
the other made. Their getters and FinalizationRegistry frees then run
against the wrong heap; the two instances hand out the same addresses, so
a late free releases a live object and corrupts the allocator.

That is the CI flake in the mobile parity suites: mobile-privacy-wire.ts
starts an init when it is imported, the first parity test starts another,
and on a slow runner the two overlap. A stackless "Out of bounds memory
access" from a finalizer then lands in an unrelated test, and every later
Krusty call traps in malloc. It hit 3 of 5 runs of 1af4c79; delaying the
first instantiation by 50 ms reproduces the same six failures locally.

createKrustyKms now shares one init promise per init function, so every
KMS built from a module, or from a copy of its exports, uses one
instance. A failed init fails every KMS built from it. The extension
builds one KMS, so its behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore/copy-review changed the rejected outcome every journey shares from
"No transfer was completed." to "Nothing changed.", which is true under a
failed deployment as well as a failed send. The merge was clean in git, but
the transaction fixture and the Swift and Kotlin copy tables it renders
still held the old wording, so the mobile-generated drift gate failed.

Regenerated with `bun run mobile:parity`; the transactions doc follows.
Mobile's deploy flow already reports a rejected or reverted deployment as a
failure rather than a deployed receipt, so no behavior changes here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ryun1 added 3 commits October 19, 2026 17:57
Brings the mobile branch up to main (812 commits since 22 Sep). The copy
review this branch had merged directly landed on main squashed (#475, #489),
so conflicting hunks take main's side; changes outside conflicts are kept.

Mobile-specific resolutions:
- vault: re-export KDF_ITERATIONS from encrypted-store, which main split
  into store/envelope-shape; the value and accepted legacy counts are unchanged
- multisig limits, recovery-row helpers and toast timings stay exported for
  the mobile generators, over main's logic
- generators follow main's API moves (passphrase-strength, privacy failure
  state, allowance listing, account networkId, deploy failure scraper, the
  onboarding account row's elevation selector)

Regenerated mobile assets and parity fixtures. Derivation, signature and
vault-envelope vectors are unchanged. Known follow-ups, failing until mobile
catches up with main's behaviour: the recovery scan cap (maxScannedIndexes),
the dictionary-aware passphrase meter, stricter relay URLs, the Argent
refusal wording, portfolio/activity parity, and nostr-relay.test.ts over the
500-line limit.
…kd's index

main replaced the fixed 0-19 recovery window with a gap limit (b5c5398):
the scan reaches defaultCount past the highest index with on-chain evidence,
capped at maxScannedIndexes. The core refused the new catalog field and kept
the fixed window, so an account at index 21 behind one at 18 was missed.

scan_target mirrors recoveryScanTarget and bounds each batch, completion and
the progress line. The catalog now carries RESERVED_AGENT_ACCOUNT_INDEX (#336)
and one is_usable_account_index refuses it for manual search, restore
selections, record creation and the next free index, as isRecoveryAccountIndex
does in the extension.
…es (#505)

Mobile derived every account at m/44'/9004'/i'/0/0. Ready (Argent X) roots its
stark tree in the Ethereum key at m/44'/60'/0'/0/0, and Braavos steps the
address field (m/44'/9004'/0'/0/i), so a real Ready phrase found no accounts
and a Braavos phrase only its first. The extension fixed this in d9edd3a.

Every key now goes through kms::private_key with a Ladder: Account (this
wallet's), Address (Braavos) or ArgentX (Ready, via Krusty's
derive_argent_legacy_private_key, never under a BIP-39 passphrase). Each
vault record stores its ladder; signing, the STRK20 viewing key, network
projection and re-derivation on open all use it.

Vault 4 -> 5 labels every existing record Account, which is how each was
derived, so no record changes address. That includes a Braavos record an
earlier scan found off index 0 on this wallet's ladder: it stays where it is
rather than moving to Braavos's ladder, where that index is another account.
The Keychain and Keystore entries, the DEK and the password slot are
unchanged; the v1->v5 golden differs only in its payload.

The scan walks recoveryScanShapes: OZ and Argent 0.4.0 on this wallet's
ladder, every Argent release on Ready's, every Braavos class on Braavos's.
Selections carry the candidate's ladder (default: the family's vendor
ladder), selection keys include the derivation kind, a vendor coordinate is
never offered for creation, and nextAccountIndex counts only this wallet's
tree. The parity fixture gains 18 Ready vectors from the extension's own
deriveAccount; all 46 derivation vectors match.

Swift's KeyPath is a stdlib type, so the enum is Ladder. The selection field
is last so Kotlin's positional constructors keep their meaning.
Ryun1 added 9 commits October 28, 2026 11:46
The merge took nostr-relay.test.ts to 509 lines, over the 500-line budget
that bun run check enforces. The Sherlock 129 paging block moves to its own
file and both share the scripted relay sockets from tests/support.
main dropped the class label from the extension's unsupported-class refusal;
the generated copy already followed, so only the two app tests still expected
"(argent 0.3.1)".
main's isNostrRelayUrl accepts a relay only as its WHATWG serialization
prints it, so WSS://relay.example is now refused. Mobile accepted any case
in the scheme, which made it looser than the extension. It now refuses
every spelling the parser would rewrite: an upper-case scheme or host, the
default or a zero-padded port, a dot segment, a query without a path, a
quote in the query, and an IP literal not in its plain form. Each refused
case was checked against the extension's own validator.

An earlier build may have stored such a spelling, and the coordination file
fails closed on an invalid relay, which would stop every multisig's sync.
Reading the file now brings stored URLs to the current rule, as the
extension's normalizeStoredWebSocketServiceUrl does: case and the default
port are fixed, a custom relay with no current spelling falls back to the
reviewed defaults, and such a checkpoint is dropped so the account resyncs.
main's setup meter reads a common password in disguise, a repeated chunk or
a keyboard run as weak (guessable.ts, Sherlock 152), so "Password1" and
"p@ssw0rd2024" no longer read fair or strong. The mobile meter still
credited them as random.

The word list is generated from the extension's own COMMON_WORDS into
mobile/core/generated/common-passwords.txt by mobile:assets, so the
mobile-generated drift test fails if the two lists part. is_guessable ports
the check: the repeat test over UTF-16 units like the regex, each edge and
leet reading, and both readings of "1". The parity vectors gain ten
passphrases that exercise every branch; all 25 match the extension.
#645)

main admits one lowercase .e suffix in the shared symbol grammar, so the
curated USDC.e is accepted, and normalises chain metadata so ETH.e is not
refused as ETH.E (a4ef27f). Its spoof check compares each ticker in its
full and base fold, so ETH.e still reads as a spoof of ETH, and the two
curated USDC contracts no longer flag each other (95f7af5).

Mobile refused USDC.e and kept the one-fold check, which would have let the
widened grammar through without its guard. Both halves are ported, and the
generated spam vectors gain the #645 cases, checked against the extension's
own tokenSpamReasons.
main's hero reads "Portfolio price · <percent>% · 24h" and each token row
tags its percentage " · 24h price" (cb37c70), so neither reads as a change
in holdings. The badge scraper also stopped at the new nested span and
recorded nothing; it now reads the badge's whole text.
… off mainnet

Three price-cache fixes on main, ported together:

- f5fddf7 (mc-wallet #357): freshness was one cache-wide time, so any
  token's refresh made a months-old price read as current. Each address now
  carries its own refresh time; a price is fresh only when every wanted
  address answered within the TTL, and asOf is the oldest contributing
  capture. A cache written before this has no per-address times and reads
  as not fresh.
- b78b050: an address the feed answers without a usable price ({} or one
  currency) loses its cached price and change, so a refresh never vouches
  for a price the feed no longer stands behind.
- 663438a (Sherlock 185): off mainnet there is no fiat by design. The
  cache holds only mainnet prices and ETH/STRK share addresses across
  networks, so Sepolia was showing mainnet USD values. Prices, changes and
  charts are now empty there.

The replayed getPrices timelines gain a "dropped" case for b78b050; every
timeline matches the extension's own createPriceService.
…the hint

The copy review moved a row's transaction hash and its copy control out of
the summary into the expansion's "Transaction" fact (93826d2); the row
scraper still read the summary, so it recorded none. It now reads the hash
row on its own and the counterparty's copy control from the remaining facts.

The pre-confirmed hint follows the extension's shorter wording.
mobile: merge main, recovery gap limit, and each family's key ladder (#505)
@Ryun1
Ryun1 removed this pull request from stack #467 October 29, 2026 12:35
@Ryun1
Ryun1 changed the base branch from chore/copy-review to main October 29, 2026 12:36

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants