Skip to content

v0.10.5: Ready/Braavos stack (#546, #548, #550, #552, #553) with review fixes, plus audit fix - #559

Merged
derek-flossman merged 41 commits into
release/v0.10.5from
fix/henri-ready-braavos-stack
Oct 6, 2026
Merged

derek-flossman merged 41 commits into
release/v0.10.5from
fix/henri-ready-braavos-stack

Conversation

@derek-flossman

Copy link
Copy Markdown
Collaborator

Brings @l-henri's Ready/Braavos stack (#546, #548, #550, #552, #553) into v0.10.5, together with every fix from four independent senior reviews, as separate labelled commits. It also includes a release-blocking dependency audit fix.

How to read this branch

  • The first 7 commits are Henri's, unchanged apart from rebase conflicts in one import line, resolved by keeping both sides.
  • Every later commit is a review fix or an integration fix from @derek-flossman. Each fix has tests that fail when the fix is reverted (mutation-checked).
  • Nothing was trimmed for size.

What the stack adds (Henri)

Commits PR Change
Home says an account is read only…, Disable Shield for a read-only account… #546 Home shows "Read only" and disables Send/Swap/Shield for accounts this wallet can't sign for (advisory; the transaction path stays authoritative).
Sign for stark-only Braavos accounts #548 Signing for deployed stark-only Braavos accounts: the bare (r, s) pair, verified live against get_signers and the deferred-removal state.
Deploy funded Braavos counterfactuals from the wallet #550 Deploys the modern Braavos class with its 15-felt deployment authorization, at exactly the counterfactual address discovered by #508.
Recover a Braavos 2FA account…, Show the recovery unlock as an absolute UTC datetime #552 Removes Braavos 2FA with the recovery phrase via deferred_remove_signers, behind Braavos's 4-day delay.
Recover a Ready account with a guardian… #553 Removes a Ready guardian with the recovery phrase via trigger_escape_guardian(None) then escape_guardian(), behind the account's security period.

Review fixes

#546 / #548: signing hardening and line caps

  • start.ts is back under the cap (scene selection moved to scene-content.ts).
  • Braavos signs only on the implementation classes (0x3957f9… current, 0x816dd0… legacy). Base and deployment classes are refused in every gate.
  • The wallet key must equal get_signers().stark[0], the signer the contract verifies.
  • atAcceptedBlock re-pins live checks on a JSON-RPC 24 miss (3 attempts, then fails closed).
  • Stale comments and the family-specific error fixed; isAccountId used for the account id.
  • New tests for webauthn-only 2FA, the expiry boundary, base or unknown classes in both gates, and the recovery probe.

#550: deployment

  • An undeployed Braavos account is refused before any signing or network call in invoke, estimate and declare (so dApp requests too). It uses a typed UndeployedBraavosError with the message "Deploy this account first (Settings → Deploy account)."
  • The same message appears in the Send review and on the dApp approval card (no fee, Approve disabled, plus a backend refusal of a forged approval).
  • Home shows "Not deployed".
  • The deploy-then-act path can no longer use the aux hash.
  • End-to-end tests for the Braavos estimate and submit: validation on, 15-felt signature, chain id, last-signed hash, aux-key mismatch.

#552 / #553: recovery

  • Confirm panels show the maximum network fee and a truthful consequence warning: the second factor is removed and the recovery phrase alone controls the account.
  • The fee is bound: the submit must be at most the reviewed ceiling (validated estimate plus 50% headroom), and at most the contract cap (100 STRK Braavos, 5 STRK Argent).
  • Hashes and lock: the signed hash must equal the accepted hash, and the lock is re-checked after signing. Activity is journaled, including outcome-unknown.
  • Ready escape window:
    • the security period is read from the chain, and the expiry deadline is shown;
    • a guardian-initiated owner escape shows a warning and needs an explicit acknowledgement, bound to that escape's kind and ready_at;
    • an escape that would install a new guardian is never presented as a removal, and "complete" is refused for it;
    • the account's one-attempt-per-12-hours limit is checked before estimating.
  • Refused accounts:
    • Ledger accounts, which can't show what they would approve;
    • accounts with a backup guardian, or whose backup can't be read.
  • RPC handling:
    • recovery gate reads are pinned to one accepted block, and fail over or re-pin rather than refusing;
    • a transient RPC failure never shows "Not deployed".
  • Messages: each refusal states its true cause.

Integration

  • Two test reconciliations and one comment rewording, for the secret-material boundary rule.
  • styles-base.ts split, with byte-identical stylesheet output.
  • UI Lab checkpoints recounted to 208.

Dependencies (release blocker)

  • fix(deps): pin patched sharp and shell-quote. Two advisories published since the last audit made check:audit fail on release/v0.10.5:
  • Both are build and dev tooling only, not in the extension. The lockfile delta is exactly those packages.

Consent copy (reviewed and approved by the release owner)

  • Braavos confirm: "Remove 2FA from this account?" / "This permanently removes 2FA from this account, along with any multisig or withdrawal limit on it. After that, your recovery phrase alone controls the account." / "Takes effect after {UTC}. Until then, you can cancel it from your Braavos device."
  • Ready start: "Remove the guardian from this account?" / "This starts removing the guardian. Once it is removed, your recovery phrase alone controls the account." / "You can complete the removal after {UTC}, until {UTC}. After that, it expires." / "Cancelling needs this account's key and your guardian together, in Ready."
  • Ready complete: "Complete guardian removal?" / "This removes the guardian from this account now. After that, your recovery phrase alone controls the account."
  • Warnings:
    • "Your guardian has started replacing this account's key. Continuing cancels that change. If you asked for it, don't continue."
    • "A change to a new guardian, started with this account's key, is pending. Continuing cancels that change. If you asked for it, don't continue."

Verification (Bun 1.3.3, whole branch at 76cd1f2)

  • bun run check: passes (2,513 tests, 0 failures)
  • check:coverage: passes (functions 95.17%, lines 95.68%, floor 95%)
  • check:audit: passes (fails on the current release branch without the deps commit)
  • bun run --filter @bramble/ui-lab check: passes (232 tests)
  • build: passes
  • test:e2e: 21/21

Reviews:

Release QA (required before shipping; on-chain)

  • Home says an account is read only before any button is pressed #546:
    • a Braavos account and a guarded Argent account show "Read only", with Send/Swap/Shield disabled;
    • plain OZ/Argent accounts look normal;
    • switching accounts never shows the flag on the wrong account.
  • Sign for stark-only Braavos accounts #548:
    • from stark-only Braavos accounts (current and legacy classes), on Sepolia and mainnet: estimate, send, sign SNIP-12 typed data, run a SNIP-9 outside execution, and use the privacy relay;
    • 2FA accounts are refused before any prompt.
  • Deploy funded Braavos counterfactuals from the wallet #550:
    • deploy a funded Braavos counterfactual on Sepolia. Verify DEPLOY_ACCOUNT with class 0x3d16c7a…, salt = public key, a 15-felt signature with sig[2] = 0x3957f9… and sig[12] = SN_SEPOLIA, and the address matching Bramble;
    • an undeployed account shows "Not deployed" and refuses Send/Swap.
  • Recover a Braavos 2FA account with the recovery phrase #552:
    • remove 2FA from a Sepolia Braavos account. Check the fee and warning are shown, the transaction is a single self-call with empty calldata and no paymaster, and the pending notice shows the right UTC time;
    • lock or restart mid-wait;
    • cancel from the device on a second account.
  • Recover a Ready account with a guardian using the recovery phrase #553:
    • on a Sepolia Argent 0.4 account with set_escape_security_period(600): start (calldata [0x1]), wait, complete (escape_guardian, then get_guardian reads 0);
    • negative cases: a second trigger within 12 hours, a backup guardian, an expired escape, a guardian-initiated owner escape (warning shown), cancel with owner+guardian, and a 0.3.x account with no recover button.

Follow-ups (not blocking)

  • A clearer message when a recovery transaction reverts after the node accepted it. Today it's reported as outcome unknown, and Activity shows the real result.
  • A background-level test for fee-ceiling pricing; the harness has no price-service hook.

Supersedes #546, #548, #550, #552 and #553, which will be closed with a pointer here.

🤖 Generated with Claude Code

l-henri and others added 30 commits October 6, 2026 17:16
A watch-only account (any Braavos today, an Argent account with a
guardian, a rotated or unsupported owner, or an unauthorizable live
release) rendered a fully normal Home - live Send, Swap and Shield, a
complete send flow, even a fee quote - and the refusal only landed at
submission.

A new privileged wallet.accountSignability message answers for the active
account: Braavos family-wide until Braavos signing lands, deployed Argent
through the live signability probe, everything else signable; advisory by
construction, so a failed probe answers signable and the transaction path
keeps its authoritative checks. A session cache mirrors the deployment
cache (both verdicts age out, since restrictions appear and lift), Home
hydration loads it beside the deployment status, and the hero renders
"Read only" in the stale-note style under the total while Send, Swap and
Shield grey out; Receive needs no signature and stays live. The verdict
vocabulary is the recovery module's restriction set plus "braavos", shared
end to end so the picker, the flag and the refusal copy can never
disagree.

Fixes #545.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 45f5284)
Review of #546: the Shield tile only switched which flow it opened for a
read-only account, so it stayed an enabled "Activate private balances"
button into a signing flow the account cannot complete — the last-click
failure #545 removes for Send and Swap. shieldTile now renders a true
disabled control for a read-only account ("Shield unavailable — this
account is read only"), and the Home test asserts it is disabled rather
than only checking Send and Swap.

Also from review: classify the wallet.accountSignability hydration poll in
the panel-poll unlock-window suite (it already uses the non-renewing
session; the suite now proves it), and add a signability-cache test for
the freshness window, the forced refresh, the account-switch drop, and the
off-Home/locked/no-account guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 9ffeb3d)
Braavos was refused family-wide at the signing boundary, though an account
whose stark key is its only signer verifies the same plain (r, s) pair
this wallet already produces - confirmed in braavos-account-cairo
(SIG_LEN_STARK accepts the bare pair) and live on mainnet from a dataset
fixture.

The blanket requireAuthorizableFamily gate is gone. In its place, the
deployed-compatibility check (now living beside its Argent sibling in
account-compatibility) verifies on chain state what the family rule
guessed: the live class must be a known Braavos release, the wallet's
stark key must be a registered signer, and no strong signer (2FA/3FA) may
be active - unless a deferred-removal request has expired by the pinned
block's own timestamp, after which the account itself treats the strong
signers as gone. Typed data and the privacy relay run the same check
through requireLiveAuthorizableClass. Deployment stays refused where every
deploy payload is built, since the extended Braavos deployment signature
is not produced here yet.

An advisory braavosSigningRestriction probe mirrors the Argent one for the
recovery scan and the Home flag: a new "strong-signer" restriction labels
rows "2FA active - view only", a stark-only deployed Braavos row reads as
a plain deployed account, and an undeployed one stays view-only. All five
Braavos refusals read properly through friendly-error-copy.

Fixes #547.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit dd45efa)
A funded-undeployed Braavos account on the modern deployment class could
only be watched: every Braavos deployment was refused because the
family's extended deploy authorization was not produced here.

The signer now assembles it exactly as the vendor app does, the layout
braavos-account-cairo pins and the vendor dataset proved on mainnet:
plain (r, s) over the deploy hash, the AdditionalDeploymentParams naming
the implementation to install (no extra signer, no limits, the chain id
stashed from the deploy details), and a second stark pair over
poseidon(params) from the same key. The parameters sign before the
transaction hash on purpose - the submission safety check treats the
key's last signed hash as the one broadcastable transaction, and only
the deploy hash is broadcastable; the first mainnet proof run caught
exactly that ordering and the test now pins it.

Deployment inputs accept only the modern deployment class (single-sourced
as BRAAVOS_DEPLOYMENT_CLASS_HASH beside the implementation it installs);
older base-class counterfactuals keep the existing refusal, since their
expected layout has not been established. Estimation signs for real with
validation on - Braavos constructors validate the deploy authorization
even on query versions, and a query-version hash can never be submitted -
while Argent keeps its mock query signer and OpenZeppelin keeps skipping
validation. The recovery picker offers "Funded and ready to deploy" for
modern-class counterfactuals, and the Home verdict treats them as
deployable like any other account.

Proven on mainnet through this exact platform path: braavos-matrix/5 and
/6 deployed (tx 0x3fcf7128...), both now running the current Braavos
implementation.

Fixes #549.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1506b25)
A Braavos account with a strong signer (2FA/3FA) was view-only with no
way forward from the phrase. Braavos exposes a phrase-only escape hatch,
and this wires it into the read-only Home flag.

A new platform module submits the one transaction such an account accepts
from the stark key alone: a single self-call to deferred_remove_signers,
empty calldata, no paymaster, and a fee bounded by the account's on-chain
cap (MAX_ETD_FEE_V3). It deliberately bypasses the strong-signer refusal
that guards every other submission, mirroring the account contract's own
carve-out, and it refuses anything that is not a deployed strong-signer
Braavos account whose stark key is registered. After the account's
execution time delay (default four days) the account treats the strong
signers as removed, so the existing signing checks start passing with no
further transaction; cancelling during the window needs the 2FA device,
which the copy says because the carve-out does not extend to the cancel.

The background exposes privileged review and submit handlers; the
signability verdict carries the recovery state (pending expiry, or the
delay a fresh request would take) so the Home flag renders all three
states: read-only with a Recover account button, read-only mid-removal
with the delay and the device-cancel note, and ordinary once the account
becomes signable. The verdict and flag follow #548's checks, which
already flip a strong-signer account to signable the moment its removal
delay expires.

Proven on mainnet through the wallet's own review/submit path:
braavos-matrix/4 (simulated 2FA) accepted the removal request (tx
0x1706142958afa1a978e291cfa159aa22e12e5c72e13834644830fc0aff73564) and
reads pending, usable after the four-day delay.

Fixes #551.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 8842a9c)
The pending read-only flag stated the delay in days; show the exact
moment the account becomes usable instead — "Usable on 09/10/26 at
13h28 UTC". Formatted with pure civil-from-epoch integer arithmetic so
the render layer reads no wall clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 886002a)
A Ready (Argent) account with a guardian was view-only with no way
forward from the phrase. Argent's escape is the symmetric feature to
Braavos' deferred removal: the owner key alone may remove the guardian,
in the two steps the account's escape carve-out accepts from a single
owner signature.

A new platform module runs that flow. trigger_escape_guardian(None),
a single self-call with the owner signature, starts the security period
(seven days by default); after it, escape_guardian() removes the guardian
and the account signs normally again — #508's checks already pass once
get_guardian is zero. It deliberately bypasses the guardian refusal that
guards every other submission, mirroring the account's carve-out, and
refuses anything but a deployed guardian-restricted account whose owner is
the wallet key. The escape fee is bounded by the account at five STRK, so
that protocol cap is the ceiling the submission honours; the reviewed fee
is the quote the user saw. Estimates run with validation on, because the
escape call does real work in __validate__ (the anti-DoS rate-limit
bookkeeping) that a skipped-validation estimate under-quotes — the first
mainnet attempt reverted out of gas on exactly that and the module now
estimates correctly. The escape state is read through the account's own
get_escape_and_status, whose status reflects the chain's clock.

The background exposes privileged review and submit handlers and the
signability verdict carries the escape stage, so the read-only Home flag
renders the Ready states alongside Braavos: a guardian account offers
"Recover account"; a running escape shows when it unlocks and that the
guardian can still cancel; once the delay passes it offers "Complete
recovery"; and it clears when the guardian is gone. Cancelling needs the
owner and guardian together, so the phrase cannot stop it alone.

Proven on mainnet through the wallet's own review/submit path:
ready-matrix/20 (guardian set) accepted the escape trigger and reads
pending, completable after the seven-day security period.

Fixes #544.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 75dd885)
start.ts had grown past the 500-line file cap (check:files failed on the
bundle). The paint loop's choice between onboarding, lock screen, and the
dashboard — and the 36-argument dashboard call — is a pure projection of
the render cache plus navigation state, so it moves to
app/screens/scene-content.ts. Behaviour is unchanged; the class-hash
verification lookup moves with it since it only fed that call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#548 accepted any class the table files under Braavos, and any stark
signer anywhere in get_signers. Neither matches the account contract:

- A live account never runs a base/deployment class (their constructors
  replace themselves with an implementation), so the live-class gates now
  accept only the implementation releases that expose get_signers and
  verify a bare (r, s): BRAAVOS_SIGNING_CLASS_HASHES (current + legacy).
  The deployment classes 0x13bfe... and 0x03d16c... are refused.
- Braavos verifies a two-felt authorization against stark[0] only. The
  wallet key must be the first stark signer; an empty list, or the key at
  stark[1], refuses as "owner does not match".

The rules move to braavos-compatibility.ts and are shared by the
authoritative gates (isCompatibleAccountDeployed, requireLiveAuthorizable
Class) and the advisory recovery-scan probe, so they cannot drift again.
The signer builder checks the STORED class, which fixes the address and
so must be a Braavos deployment class; the implementation is checked live.

Tests pin both gates and the probe on: base/deployment class refused,
stark[1]-only refused, empty stark list refused, webauthn-only 2FA
refused, the expiry boundary (expire_at == block time refuses, one second
earlier signs), one pinned block for every read, the typed-data and relay
gates refusing a base class, the builder refusing an implementation class
as stored class, and the recovery scan labelling Braavos rows through the
Braavos probe. Fixtures that used a base class as the LIVE class move to
the current implementation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Argent typed-data branch already re-pins when a load-balanced sibling
has not seen the accepted block (JSON-RPC 24); the Braavos branch added
in #548 pinned once, so roughly one Braavos typed-data or relay request
in eight would fail on the wallet's own endpoint. The submit-path check
(isCompatibleAccountDeployed) pinned without any retry for Argent and
Braavos, and #548 had also started pinning the Argent multisig class read.

One helper now runs every read behind a decision at a freshly pinned
block and re-pins the whole decision on block-not-found, up to three
times, then fails closed. Both gates use it for Argent and Braavos. The
multisig check is a single class read with nothing to keep consistent,
so it reads at latest again, as before #548.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After #548 Braavos is no longer view-only, but chain-ops and the
multisig deploy view still said so, and the getBraavosSigningRestriction
doc block sat under a stray copy of the Argent probe's comment.

isCompatibleAccountDeployed threw "RPC returned an invalid Argent account
class hash" for a malformed class answer on any family, Braavos included.
It now says "RPC returned an invalid account class hash" and translates
to "Couldn't read this account. Try again." (it had no friendly copy and
was shown verbatim). The multisig deploy view keeps Braavos out of the
funding list; only its comment changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review nits from #546: isAccountSignabilityResponse accepted any string
as accountId, though the id keys the signability cache and is compared
with the active account id; it now uses the shared isAccountId grammar,
like isDeploymentStatusResponse beside it. A duplicated JSDoc block above
RecoveryState is dropped, and isRecoveryState's comment now says what it
validates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
check:coverage already failed on the bundle base (functions 94.86%
against the 95% floor); the recovery stack's new side-panel client
(runtime-recovery.ts) sat at 20% function coverage, and the scene
selection split out of start.ts was only reached through start.ts.

sceneContent is pinned branch by branch, including the dashboard's
positional argument mapping and the rule that class-hash verification is
shown only for the account it belongs to (a mutation that drops that rule
fails). The recovery client is pinned on its four message types, the
fallback reason for a malformed reply, and passing an error reply's own
reason through. Coverage is now functions 95.07%, lines 95.45%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #550: an undeployed modern-class Braavos account reached the
automatic deploy-then-invoke (and deploy-then-declare) paths, which
cannot work for it. The bulk estimate gives every non-Argent family
skipValidate, so the DEPLOY_ACCOUNT half carried an empty signature the
Braavos constructor rejects; and ensureAccountDeployed was handed the
first signed hash as the expected deploy hash, which for Braavos is the
deploy signature's parameters hash, so a deployment that did go out
would have been reported as status unknown.

Invoke estimate/submit and declare estimate/submit now refuse an
undeployed Braavos account right after the deployment read, before any
signing, estimation, or broadcast: "Deploy this account first
(Settings → Deploy account)." for the modern class, and the existing
"does not deploy Braavos accounts" for an older base class.
ensureAccountDeployed refuses Braavos too, so the aux hash can never be
taken for the broadcast one. dApp requests reach the same platform
functions, so they are covered by the same gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #550: the Home verdict treated a modern-class Braavos
counterfactual as signable, so Send and Swap stayed live into a
deploy-then-act submission that cannot work for Braavos (and that the
transaction path now refuses).

The verdict now answers readOnly with a new "undeployed" reason for it,
added to ReadOnlyReason and its strict validator. Home greys Send and
Swap and says "Not deployed — Deploy this account first (Settings →
Deploy account)." instead of "Read only", which would be untrue: the
account signs once its own reviewed deployment lands. Shield keeps its
existing deploy-first route rather than becoming a dead control. An
older base-class counterfactual keeps the "braavos" read-only verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #550: the Braavos deployment path had no platform test through
estimateAccountDeploymentFee or submitAccountDeployment, so three
mutants survived — Braavos-only skipValidate in the estimate or the
submission, the submission estimating with the Argent mock query signer,
and dropping the public-key check on the parameters signature.

The new tests run both through the real signer against a Sepolia stub:
validation stays on, the 15-felt signature carries the implementation at
sig[2] and the provider's chain id at sig[12], each stark pair covers
the hash it should, and the accepted hash is the last one signed (a node
answering with the parameters hash is an unknown outcome). A parameters
signature from another key is refused before any estimate or broadcast.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #550: the module header still said OpenZeppelin accounts only,
and estimateAccountDeploymentFee claimed it never signs privately, which
is false for Braavos - its constructor validates the deploy
authorization even on query versions, so the estimate signs a
query-version hash (never submittable) with the account key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow-up to the #550 review: an undeployed Braavos account's Send (and
NFT send) review degraded to "Couldn't estimate ... retry", offering a
retry that can never succeed - the account deploys only through its own
reviewed step.

The refusal is now single-sourced and typed. UNDEPLOYED_BRAAVOS_REFUSAL
and isDeployableBraavos live in wallet-model beside the deployment class
(the platform gate, the Home verdict and notice all read them), and the
platform gate throws UndeployedBraavosError, split into its own module
by the size budget and re-exported from deployment. reviewWalletFundedFees
throws it up front for an undeployed modern-class Braavos account,
before any estimate (so the deployment's query signature is never
produced), and passes the platform's typed error through if the two
deployment reads disagree. The handler error reaches the panel as
"Deploy this account first (Settings → Deploy account)."; every other
estimate failure stays a retryable review, matched by type, not text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow-up to the #550 review: a dapp invoke from an undeployed Braavos
account was reviewed like any undeployed account ("The network fee is
estimated once this account is deployed."), which suggests the request
would deploy it - it cannot; the account deploys only through its own
reviewed step.

estimateInvokeApprovalFee now answers a deploy-first review for it, by
account state before any estimate or signer is built, or on the
platform's typed UndeployedBraavosError; every other failure keeps the
retryable state. The new `deployFirst` flag travels on PendingApproval
(queue, snapshot, fee updates - a fresh review replaces it rather than
inheriting it) and the strict validator accepts it only in its one
consistent shape on an invoke. The card shows "Deploy this account
first (Settings → Deploy account)." with no fee and no retry, Approve
stays disabled, and a forged approval is refused with the same text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow-up to the #550 review: the review said neither estimate signs,
which stopped being true in general when Braavos deployment estimates
began signing a query-version deploy with the account key. It holds here
only because an undeployed Braavos account is now refused before either
estimate; the comment says that, and why the order matters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow-up to the #550 review: the new "undeployed" Home verdict had no
UI Lab checkpoint. walletDashboard now takes the signability verdict
(absent renders a normal account, as before), the portfolio scenario
gains "undeployed-braavos", and its story asserts the "Not deployed"
notice and its deploy-first line, disabled Send and Swap, and no "Read
only". README checkpoint total recounted from walletScenarios: 208.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Integration fix: three comments added by the #550 fixes used the word the secret-material boundary check reserves for key/vault/signing modules (scripts/file-standards-policy.ts). Reworded the comments; no code change, and the approved-file list is not widened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pending Ready flag said "Your guardian can still stop this". The
account's cancel_escape needs the owner and the guardian signatures
together, so the guardian alone cannot stop it and neither can the phrase.
State that instead. The Ready confirm label also claimed the account would
be "usable in 7 days" when completing an escape is immediate; drop the
delay from the label.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The removal request was estimated with skipValidate, so the quoted fee
skipped the account's __validate__ work. Estimate with validation on, as a
Braavos deployment does (the phrase key signs only the query-version hash).

The review now states a maximum fee: the validated spend plus the wallet's
stated 50% headroom, never above the account's 100 STRK cap. The submission
is held to that figure exactly (and to the cap, and to STRK), so the number a
confirmation shows is a true ceiling. The review also returns the moment the
removal takes effect if sent now (latest block time plus the account's
delay), refuses a request already pending, and refuses Ledger accounts.

At submit the signer re-checks the unlock session after signing and reports
the hash before broadcast, the accepted hash must match the signed one
(matchingAcceptedRpcWriteHash, so a mismatch is post-broadcast ambiguity),
and a failure after acceptance is indeterminate rather than a determinate
error. The shared guards live in recovery-shared.ts for both recovery flows.

Platform tests now pin the exact call, the validated estimate, the ceiling
and cap, the lock and hash checks, the account refusals (including an
expired request and a foreign stark key), and the submit path.

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

The guardian escape hardcoded a seven-day security period, read the escape
through a second provider, reported a pending owner escape as "none", and
held the submit only to the unit and the 5 STRK cap.

- The security period comes from get_escape_security_period; the state now
  carries the completion window (ready_at until ready_at + period, after which
  the escape expires) and the review states it: estimated from the latest
  block for a start, the account's own for a completion.
- A live owner escape (a guardian-approved key replacement, which
  trigger_escape_guardian silently cancels) is reported. Starting then needs
  an explicit replaceOwnerEscape acknowledgement, re-checked at submit.
- The submit must name the step the user confirmed; a changed step (an
  escape that became ready, expired or was cancelled meanwhile) is refused.
- Fees: the review states the validated spend plus the stated 50% headroom,
  capped at 5 STRK; the submit is held to that ceiling exactly, to the cap,
  and to STRK, with a zero tip (inside the 1 STRK tip cap).
- Estimates run validated through the Argent query signer, which the
  account accepts for query-version transactions, so the review never asks a
  key to sign — no Ledger prompt, no phrase key. Ledger accounts are refused
  outright: the device could only blind-sign a guardian removal.
- Lock re-check, signed-vs-accepted hash matching and post-acceptance
  indeterminacy come from recovery-shared.ts, as for Braavos.

The background now passes the period through; it does not yet send the
confirmed step, so a Ready submit fails closed until the confirmation lands
in the following commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recovery review/submit handlers move to account-recovery.ts (re-exported
from account-signability so the wallet-ops table is untouched) and change in
four ways:

- A submit names the account its confirmation was reviewed for; a switch in
  between is refused. Ready submits must name the confirmed step, and may
  carry replaceOwnerEscape: true only (recovery-inputs.ts, exact key sets).
- Ledger accounts are refused before any estimate or signer is built.
- Every accepted submission is recorded to activity ("Start 2FA removal",
  "Start guardian removal", "Complete guardian removal"). A lost or
  mismatched reply is recorded under the signed hash and returned as the
  typed submission-unknown reply, never as a determinate failure.
- Both reviews return one shape for the confirmation: step, maximum fee,
  readyAt (Braavos: takes effect after; Ready: completion opens), expiresAt
  (Ready's completion deadline) and replacesOwnerEscape.

The signability verdict now carries Ready's completion window and stage for
pending, ready and expired escapes, and reports a pending owner escape as
stage owner-escape instead of nothing. Wire types move to recovery-types.ts
(runtime-types was at the size cap). Key-boundary catalog entries carry live
payloads; the two new refusals have reviewed panel copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recover-account button relabelled itself on the first tap and submitted
on the second, showing neither the fee nor what the removal does, and the
armed state lived on the button indefinitely.

The first press now reviews and replaces the read-only flag's body with a
confirmation built from DOM nodes (no HTML sink): what is removed, when it
takes effect or can be completed (absolute UTC, from the chain), who can
still cancel, the maximum network fee in STRK, and explicit Cancel and
confirm buttons. The panel resets on Cancel, after 60 seconds, and with any
re-render (it never attaches to a flag replaced while the review was in
flight). A submit sends back exactly what was shown: account, fee, step,
and the owner-escape acknowledgement only when the panel warned about it.
After any submit the panel is spent and Home force-refreshes the verdict.

The flag states Ready's completion window and deadline, says when an escape
expired (restarting only through the confirmation), warns when the guardian
is replacing the key instead of showing nothing, and tells a Ledger account
recovery is unavailable instead of offering it. Recovery copy and the
notice move to recovery-view.ts and the binding to recovery-ui.ts (size
budget); the reply validators to responses-recovery.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
trigger_escape_guardian(None) is rejected in __validate__ while a backup
guardian is set (argent/backup-should-be-null), so such an account reached
the review only to fail with a raw RPC revert.

The platform now reads get_guardian_backup before any estimate or signer:
a backup guardian is refused with a stated reason, and a failed or empty
read refuses too (fail closed) — transport errors keep their own
classification and endpoint fallback. The recovery state carries
guardianBackup (absent / present / unknown); the signability verdict maps
present and unknown to the stages backup-guardian / backup-unknown, ahead
of any other Ready stage, so Home shows no Recover button and says why.
Both platform refusals have reviewed panel copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Integration fix:
- The undeployed-Braavos signability test (from the #550 fixes) used the placeholder account id "a", which the #546 fix now correctly rejects with isAccountId. Use a valid id.
- Remove the client test written for the pre-rewrite recovery runtime client; the #552/#553 fixes rewrote that client and runtime-recovery.test.ts covers it (100% functions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Ready start's acknowledgement was a bare boolean, so an owner escape
cancelled and re-triggered between review and submit was cancelled under
consent given for a different one. The recovery state now reports the
pending escape with its ready_at, the review returns it as `replaces`,
and the submit carries `replaceEscapeReadyAt`: the platform refuses unless
it names exactly the escape found at submit. The background parser accepts
only a positive safe integer for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
derek-flossman and others added 11 commits October 6, 2026 17:28
readEscapeState ignored the escape's new_signer. The owner key alone can
trigger_escape_guardian(Some(new)) — from the Ready app, say — and
escape_guardian() then installs that guardian, but the wallet showed it as
"Removing the guardian", offered "Complete recovery", and its confirmation
said the phrase alone would control the account.

The Option<SignerStorageValue> serde is now parsed (None = [1], Some =
[0, stored_value, signer_type]; anything else fails closed). A guardian
escape to a new guardian is a pending change, not a removal: its status
reads none, so review and submit never take the complete step; a live one
surfaces as stage guardian-change with a neutral notice, and starting a
removal (which trigger_escape_guardian resets it for) needs the same
ready_at-bound acknowledgement as an owner escape, with its own warning.
An expired one is inert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Braavos and Ready pre-sign gates read the live class, signers, owner,
guardian, backup guardian, pending request and escape at `latest` over
several calls, so a decision could combine states that never coexisted.
They now run under account-compatibility's atAcceptedBlock (exported for
reuse, with its JSON-RPC 24 re-pin), and the two advisory restriction
probes take the pinned block identifier (latest stays their default for
the scan and the Home flag). For Ready, the escape state the step is
decided from is read at the same block as the gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recovery gates reported "owner does not match the wallet signer" for
every restriction other than the one they lift, including an unrecognised
release and (Ready) an unsupported owner type. Each restriction now names
its own cause, reusing the strings the transaction path already throws:
an unrecognised Braavos release, an unsupported Argent owner type, a
changed owner, and for an Argent release "not one this wallet can
recover" (true both of an unknown class and of a known one whose
authorization this wallet cannot produce).

The 2FA/guardian flow also stops borrowing the account-discovery fallback
("Couldn't recover accounts…"): its runtime and background fallbacks are
now its own diagnostics with their own panel copy, as are the escape
decoding refusals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
handleAccountSignability turned a failed deployment-status read into
"undeployed", so a deployed Braavos account flashed "Not deployed" on a
transient RPC failure. A failed read is now no verdict: the flag answers
signable, as the handler's doc says a failed probe does, and the
transaction path keeps its own fail-closed checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The re-render test only looked in the live document, where a panel built
into the discarded flag is invisible anyway, so removing the isConnected
guard survived. It now checks the discarded flag itself and that no expiry
timer was armed for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
styles-base.ts sat at 499 lines. The flow-screen header, grouped-list,
option-row and action-row rules move verbatim to core/styles-lists.ts,
composed immediately after BASE_STYLES, so SIDEPANEL_STYLES is
byte-identical (checked by diffing the composed stylesheet) and
styles-base.ts drops to 373 lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Within 12 hours of a previous escape attempt the account's
assert_valid_escape_parameters rejects the step with
argent/last-escape-too-recent, which reached the user as a raw RPC error.

At the same pinned accepted block as the gate and the escape state, the
step's own clock is now read — get_last_owner_trigger_escape_attempt for a
start, get_last_owner_escape_attempt for a completion — and the step is
refused unless the block time is strictly after it plus 12 hours, exactly
the account's condition. An unreadable clock refuses. As a backstop for a
race past that check, an estimate that reverts with exactly that reason
(as text or as its felt short-string) maps to the same refusal; any other
failure passes through unchanged. Both refusals have reviewed panel copy.

The escape-state reads move to argent-escape-state.ts (size budget).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two advisories published after the last audit made check:audit fail on release/v0.10.5 (the promotion PR and nightly run it):
- GHSA-pqg4-j6r4-53mv (critical): shell-quote quote() command injection, affected >=1.8.4 <1.11.0. Reached through web-ext > fx-runner, which pins 1.10.0. Pin 1.11.0.
- GHSA-wq5f-xc86-pv6w (high): sharp via its librsvg dependency, affected <0.35.5. Reached through the website's wrangler > miniflare. Bump the existing pin 0.35.4 -> 0.35.5 (libvips 1.3.3 -> 1.3.4).
Both are build/dev tooling and not in the extension bundle. The lockfile delta is exactly those packages; check-config-standards pins the new values. Same pattern as the source-map-js pin (#555).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The escape-limit clock read swallowed every failure into "Couldn't check
this account's escape limit", so a transport failure never reached
withRpcFallback's next endpoint and a JSON-RPC 24 miss on the pinned block
never reached atAcceptedBlock's re-pin. The backup-guardian gate read
rethrew transport but not the block miss, and the Home state's backup read
swallowed both.

isRetryableReadError (transport or block-not-found) now propagates from all
three; only an answer from the account (or an empty one) maps to its
fail-closed result. The other pinned-gate reads (class, signers, owner,
guardian, escape, period, Braavos pending request) never caught at all.

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

An acknowledgement named only the pending escape's ready_at, so a guardian
change and an owner escape at the same ready_at were interchangeable.
The submit now carries replaceEscape: { kind, readyAt } end to end — UI
client, strict background parser (exact keys, known kind, positive whole
ready_at), platform input — and the platform refuses unless both match the
escape found at submit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
recoveryContext refused an account without publicKeyX with "second factor
removal unavailable", whose panel copy blames the connection. Every
AccountRef shape carries publicKeyX (AccountRefCore), so the branch could
never run; it is removed and the reason documented, and a non-vendor
contract type stays refused by the platform gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@l-henri l-henri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Note: I co-wrote the underlying stack (#546/#548/#550/#552/#553), so I focused my scrutiny on @derek-flossman's review-fix commits — the content I did not write — and read the highest-risk changes directly rather than trusting the description.

Verified by reading the code:

  • My seven base commits came through intact — the only patch-id drift is a benign handleSetAccountHidden import-list merge (the #540 integration) across three commits plus one start.ts comment; no logic change.
  • Braavos class gating (bab18225) — signing is correctly refused for base/deployment classes and strong signers before the key is touched; only implementation releases on the first stark signer sign.
  • New-guardian escape safety (41350e9b) — a guardian escape that installs a new guardian is parsed from its Option serde (variant 1/len 4 = removal, variant 0/len 6 = change) and is never presented or completed as a removal. Real safety fix.
  • Deps (63e84e77) — sharp 0.35.5 and shell-quote 1.11.0 lockfile integrity hashes both match the npm registry exactly; versions match the overrides; config guard tightened. Build/dev tooling only.
  • CI green on this head.

Trusted (not individually read): the remaining ~20 derek hardening commits (fee/hash/lock binding, backup-guardian/Ledger refusals, RPC re-pin), via green CI + the four senior and two bundle reviews with clean mutation runs, and because each is a refusal or a binding (tightening, not loosening).

Non-blocking: the body's "unchanged apart from one import line" slightly undercounts the rebase reconciliation (benign). The on-chain release QA (Sepolia escape/deploy/2FA runs) is a real pre-ship gate, and the revert-after-accept recovery message + fee-ceiling background test remain as follow-ups.

@derek-flossman
derek-flossman merged commit cedb570 into release/v0.10.5 Oct 6, 2026
6 checks passed
@derek-flossman
derek-flossman deleted the fix/henri-ready-braavos-stack branch October 6, 2026 17:03
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.

2 participants