Map the changed area to the smallest browser smoke workflow that still proves the behavior. Combine workflows when a shared-layer or SDK change crosses multiple categories.
Run validation first:
(
cd packages
npm ci
npm run build -w @openzeppelin/guardian-client
npm test -w @openzeppelin/miden-multisig-client
)
(cd examples/smoke-web && npm run typecheck && npm run build)
(cd examples/web && npm run build)Primary smoke server commands:
cargo run -p guardian-server --bin server
cd examples/smoke-web && npm run devDefault startup choices:
- one GUARDIAN at the chosen Deployment Target (see the table in
SKILL.md):- Local dev:
http://localhost:3000 - Staging (devnet):
https://guardian-stg.openzeppelin.com - Production (testnet):
https://guardian.openzeppelin.com
- Local dev:
- one
examples/smoke-webdev server athttp://localhost:3002(or the scratch deployed-SDK project when smoking the published npm package) - one real browser or fully isolated browser profile per cosigner (Chrome MCP's
tabs_create_mcptabs count as one profile, not three — see Browser Automation inSKILL.md) - Miden RPC matching the chosen target:
- Local dev or Staging:
https://rpc.devnet.miden.io - Production:
https://rpc.testnet.miden.io
- Local dev or Staging:
- signer source:
local - signature scheme: Falcon unless the task specifically targets ECDSA or Miden Wallet
If 3002 is occupied by another local app, start examples/smoke-web on a free port and use that exact URL consistently in every browser and automation command.
Default session bootstrap in each browser console when page-load bootstrap did not already succeed, or when you need to override the defaults:
await window.smoke.initSession({
guardianEndpoint: 'http://localhost:3000',
midenRpcEndpoint: 'https://rpc.devnet.miden.io',
signerSource: 'local',
signatureScheme: 'falcon',
browserLabel: 'A',
});Record commitments from await window.smoke.status(). For local signers, use status.localSigners.falconCommitment or status.localSigners.ecdsaCommitment. For Miden Wallet, use status.midenWallet.commitment.
Use await window.smoke.events() as the primary timing source for command durations. Record extra manual timings for wallet modal latency, faucet confirmation, and canonicalization lag.
If a window.smoke.* call throws an opaque value such as [object Object], read the newest matching entry from await window.smoke.events() and classify the error from that event instead of from the thrown value.
If page-load bootstrap fails with ConstraintError: Key already exists in the object store, record that failure, then retry with an explicit await window.smoke.initSession(...) before abandoning the workflow. If page-load bootstrap already reached ready, avoid an immediate second initSession() on that same profile.
Use when:
- the prompt asks for a general smoke test
- the change spans multiple TS/browser multisig flows
- the prompt does not narrow the target behavior yet
Steps:
- Start GUARDIAN and
examples/smoke-web. - Open browser/profile A, B, and C at the active smoke harness URL.
- Run
initSessionin each browser with uniquebrowserLabel. - Capture each browser's commitment from
status(). - In browser A, create the multisig using B and C commitments:
await window.smoke.createAccount({ threshold: 2, otherCommitments: ['0x...', '0x...'], });
- Capture the
accountIdfromstatus().multisig.accountIdin browser A. - In browsers B and C, load and sync the account:
await window.smoke.loadAccount({ accountId: '0x...' }); await window.smoke.sync();
Expect:
- all browsers point to the same GUARDIAN server and testnet
- account creation succeeds in browser A
- the other browsers can load and sync the same account
- all browsers now represent cosigners of the same multisig
Canary checks:
- report any bootstrap failure before a recovered
initSession()as a transient but real canary failure - prefer Chrome + Brave or Chrome + Firefox over multiple tabs in one browser
Use when:
- the prompt asks for a default create/sign/execute canary
- proposal creation, signing, execution, or post-execute sync changed
- you need a smoke test that does not depend on notes or external faucet state
Setup:
- Start from the baseline harness with browsers A, B, and C.
- Create the initial multisig with A and B only.
- Keep C unbound to the account so its commitment can be added later.
Steps:
- In A, create the add-signer proposal:
const created = await window.smoke.createProposal({ type: 'add_signer', commitment: '0xC_COMMITMENT', increaseThreshold: false, });
- In B, sync if needed, then sign:
await window.smoke.sync(); await window.smoke.signProposal({ proposalId: created.proposal.id });
- In A, sign too. In the default 2-of-2 initial account, both existing cosigners must sign before the add-signer proposal becomes executable:
await window.smoke.signProposal({ proposalId: created.proposal.id });
- In A or B, execute:
await window.smoke.executeProposal({ proposalId: created.proposal.id });
- If execute returns
Refusing to overwrite local state: incoming nonce ... is not greater than local nonce ..., record the error and keep syncing A and B until the canonicalized 2-of-3 state appears. Treat the first post-executesync()nonce-overwrite the same way. - In C, load the updated account using the shared
accountId.
Expect:
- proposal creation succeeds in A
- B can sync, see the proposal, and sign it
- A also signs before execute in the default 2-of-2 setup; B signing alone is not enough to move the proposal to
ready - execution may return a reportable nonce-overwrite error before server canonicalization finishes; the pass condition is eventual convergence to the updated signer set
- existing cosigners may see temporary nonce-overwrite sync failures before canonicalization finishes, then resync successfully
- C may initially fail
loadAccount()with unauthorized auth before canonicalization finishes, then load the updated account successfully
Canary checks:
- report canonicalization lag separately from execute time
- if execute first fails with the nonce-overwrite error but later sync converges to the expected state, report it as a recovered failure rather than a terminal canary failure
- if post-execute
sync()orloadAccount()first fails with nonce-overwrite or unauthorized auth but later converges, report the first failure and the later recovery rather than treating it as a terminal canary failure - if C initially cannot load the account, report the first failure and the eventual recovery
- if A or B executes successfully but C never becomes able to load, mark the canary failed
Use when:
- the prompt asks to send a payment
- note ingestion, note consumption, or P2ID transfer behavior changed
- vault updates or post-execute received-note behavior changed
Setup:
- Start two browsers, A and B.
- Create a 2-of-2 account and load it in both browsers.
- Record the
accountIdfromstatus().multisig.accountId.
Steps:
- Open Miden Faucet.
- Paste the
accountIdintoRecipient addressand send a public note. - In A and B, poll
sync()andlistConsumableNotes()until the note appears. - In A, create a consume-notes proposal with the note ID:
const notes = await window.smoke.listConsumableNotes(); const consume = await window.smoke.createProposal({ type: 'consume_notes', noteIds: [notes[0].id], });
- In B, sign the proposal. Then execute from A or B.
- If execute returns
Refusing to overwrite local state: incoming nonce ... is not greater than local nonce ..., record it and keep syncing until the vault becomes non-empty. - Create a self-addressed P2ID proposal using the same
accountIdand a vault asset:const { detectedConfig, multisig } = await window.smoke.status(); const asset = detectedConfig.vaultBalances[0]; const payment = await window.smoke.createProposal({ type: 'p2id', recipientId: multisig.accountId, faucetId: asset.faucetId, amount: asset.amount, });
- Sign and execute the P2ID proposal.
- If execute returns the nonce-overwrite error, record it and keep syncing until a new received note is visible.
Expect:
- faucet mint succeeds before browser-side sync starts
- the new note becomes visible after sync
- consume-notes executes and the vault gains assets
- the self-P2ID executes successfully
- a new received note becomes visible after the final sync
Canary checks:
- report faucet page or confirmation failures explicitly
- if the note never appears after repeated sync, report the attempt count and wait time
- if either execute first returns the nonce-overwrite error but later sync converges, report it as a recovered failure with the canonicalization lag
- if consume executes but the vault remains empty, mark the canary failed
- if self-P2ID executes but no new note appears after final sync, mark the canary failed
Use when:
- private P2ID note behavior or note export/import changed (issues #322, #356)
exportNote/importNote/getP2idNoteIdharness commands changed- the prompt asks to validate out-of-band note transfer or private note consumption
Setup:
- Run the
payment-roundtrip-canarysetup through vault funding: browsers A and B on one 2-of-2 account, faucet receipt consumed so the vault holds an asset.
Steps:
- In A, create a self-addressed private P2ID proposal:
const { detectedConfig, multisig } = await window.smoke.status(); const asset = detectedConfig.vaultBalances[0]; const payment = await window.smoke.createProposal({ type: 'p2id', recipientId: multisig.accountId, faucetId: asset.faucetId, amount: asset.amount, noteType: 'private', });
- Still in A, resolve the note ID before executing (it derives from the pre-execution vault state):
const { noteId } = await window.smoke.getP2idNoteId({ proposalId: payment.proposal.id });
- In B, sign the proposal. In A, execute it.
- In B,
sync()and confirmlistConsumableNotes()does NOT show the private note (only its commitment is on chain; B has a separate local store). - In A, export the note file:
const { noteFileBase64 } = await window.smoke.exportNote({ noteId });
- Hand the base64 string to B out-of-band (copy between consoles) and import it there:
const imported = await window.smoke.importNote({ noteFileBase64 });
importNotesyncs afterwards; the note should appear inimported.status.consumableNotes. - In B, create a consume-notes proposal with the imported note ID.
- In A,
sync()until the note appears inlistConsumableNotes()there too, then sign the proposal. (The sender's store knows the full note and self-heals once a sync attaches the inclusion proof; a cosigner browser that never created nor imported the note mustimportNotefirst.) - Execute, sync both browsers, and verify the vault balance reflects the reconsumed asset.
Expect:
- the proposal metadata carries
noteType: 'private' getP2idNoteIdreturns the same ID the export later resolves- before import, B cannot see the private note via sync
exportNotereturns non-empty base64 andimportNotein B returns the note ID- after import, the note is consumable in B and the consume proposal executes normally
Canary checks:
- if the private note IS visible in B before import, report it — the note leaked publicly and the private path is not being exercised
- if
exportNotefails with a not-found error in A after execution, report it with the exact message - if import succeeds but the note never becomes consumable after sync, report the sync attempt count and elapsed wait
- if signing fails with
metadata does not match tx_summary, the signer's store does not yet hold the note with its inclusion proof — sync (orimportNote) until the note lists as consumable and retry; report it as a failure only if it persists after that - if consume execution fails with a note-binding or missing-note error, report it with the exact message
- record elapsed time for P2ID execute, export, import, first consumability after import, and consume execute
Use when:
- the prompt asks to switch GUARDIAN providers
Switch GUARDIANtransaction behavior changed- export/import/offline sign behavior changed
Important:
- the browser harness uses GUARDIAN HTTP endpoints, not gRPC endpoints
- keep host literals consistent; prefer all
127.0.0.1or alllocalhost - the current smoke harness does not expose the active post-switch GUARDIAN endpoint in
status(), so verification must be behavior-based
Setup:
- Start GUARDIAN A on the default ports.
- Temporarily change
crates/server/src/main.rsso GUARDIAN B uses alternate HTTP and gRPC ports. - Start GUARDIAN B with distinct storage directories.
- Fetch GUARDIAN B's commitment from its HTTP endpoint. Match the query to the active signer scheme:
For ECDSA runs, use:
curl http://127.0.0.1:3001/pubkey
curl 'http://127.0.0.1:3001/pubkey?scheme=ecdsa' - Start browsers A and B against GUARDIAN A.
- Create a 2-of-2 multisig and load it in both browsers.
- Kill GUARDIAN A.
Steps:
- In A, create the switch proposal:
const created = await window.smoke.createProposal({ type: 'switch_guardian', newGuardianEndpoint: 'http://127.0.0.1:3001', newGuardianPubkey: '0xNEW_GUARDIAN_COMMITMENT', });
- Export it:
const exported = await window.smoke.exportProposal({ proposalId: created.proposal.id });
- In B, import it and offline-sign it:
await window.smoke.importProposal({ json: exported.json }); const signedByB = await window.smoke.signProposalOffline({ proposalId: created.proposal.id });
- In A, import B's signed JSON, offline-sign it too, then import the fully-signed JSON:
await window.smoke.importProposal({ json: signedByB.json }); const signedByA = await window.smoke.signProposalOffline({ proposalId: created.proposal.id }); await window.smoke.importProposal({ json: signedByA.json });
- Execute the fully-signed proposal from A.
- With GUARDIAN A still down, attempt a post-execute
sync()or fresh account load that can only succeed if the account is now using GUARDIAN B.
Expect:
- switch proposal creation/export/import/offline sign succeed
- both required offline signatures are present before execute
- execute succeeds
- follow-up sync or account interaction succeeds while GUARDIAN A remains down and GUARDIAN B remains up
Canary checks:
- if post-switch behavior cannot distinguish A from B, report that as a harness gap
- if import or offline sign fails due stale chain state, report the exact error and whether an immediate pre-import
sync()recovered it - if execute is attempted before the proposal is fully signed, report that as test-sequencing failure rather than SDK failure
- if execute succeeds but post-switch sync still depends on the dead GUARDIAN, mark the canary failed
Use when:
- Miden Wallet integration changed
- wallet extension detection or external signing changed
- ECDSA external signer resolution changed
- the prompt explicitly asks for wallet validation
Steps:
- Initialize the session in a clean browser with the extension installed.
- Run:
await window.smoke.connectMidenWallet(); const status = await window.smoke.status();
- Verify
status.signerSource === 'miden-wallet'andstatus.midenWallet.connected === true. - If the trigger was
ECDSA external signer resolution changed, also verifystatus.midenWallet.scheme === 'ecdsa'; a Falcon-only wallet run does not exercise external ECDSA resolution and does not satisfy that trigger. - If the changed code path affects signing, create or load a small multisig and run at least one sign operation through the wallet.
Expect:
- wallet connection succeeds
- commitment, public key, and scheme are visible in
status().midenWallet - for an ECDSA resolution trigger,
status.midenWallet.schemeisecdsa - any requested signing path succeeds with the wallet as the active signer source
Use when:
- commitment verification changed
- sync-after-execute behavior changed
- the prompt explicitly asks to compare local vs on-chain state
Steps:
- Load a multisig and run
sync(). - Fetch decoded state:
await window.smoke.fetchState();
- Verify commitments:
await window.smoke.verifyStateCommitment();
- If the task changed execute behavior, run the verification again after a proposal execute and post-execute sync.
Expect:
- state fetch succeeds
- local and on-chain commitments match
- post-execute state verification reflects the updated account state
Use when:
recoverByKey,lookupAccountByKeyCommitment,lookup_grpc.rs, or any related code path changed.- The recovery UX shape changed (signer scope, return shape, error semantics on per-match
getState).
Signer regeneration (Outcome Y). examples/_shared/multisig-browser/initClient.ts regenerates Falcon and ECDSA local signers on every page load (AuthSecretKey.rpoFalconWithRNG(undefined)). A device-loss simulation via clearLocalState + reload would lose the signer that authorizes the just-created account. The canary therefore asserts the SDK round-trip (key → lookup → state), not seed-derivation persistence. A future change that persists local signers across reset would unlock the broader simulation.
Browser-vs-Rust create flow difference. In examples/demo (Rust), the Create multisig account action atomically (a) builds the account, (b) submits to Miden RPC, and (c) registers with GUARDIAN. In examples/smoke-web, window.smoke.createAccount only does (a) and (b); GUARDIAN registration is a separate window.smoke.registerOnGuardian() call (mirroring the "Register on Guardian" button). If you skip step 5 below, recoverByKey will return [] because GUARDIAN has no record of the account yet — that's expected behavior, not a regression.
- Start GUARDIAN and
examples/smoke-web. - Browser A: page-load bootstrap reaches
ready. Capturestatus().localSigners.falconCommitmentascoldCommitment. - Browser B: bootstrap, capture commitment.
- Browser A:
Capture
await window.smoke.createAccount({ threshold: 2, otherCommitments: ['<browser-B commitment>'], });
status().multisig.accountIdasoriginalAccountId. - Browser A — register the account with GUARDIAN. Required; without this, GUARDIAN has no authorization record for
coldCommitmentand step 6 will return[]:await window.smoke.registerOnGuardian();
- Browser A (same session — keys still in memory):
Assert:
const matches = await window.smoke.recoverByKey();
matches.length >= 1and at least one entry'saccountIdequalsoriginalAccountId. - Browser A: paste
originalAccountIdinto the Account ID field, runawait window.smoke.loadAccount({ accountId: originalAccountId }), thensync, thenverifyStateCommitment. Assert:localCommitment === onChainCommitment. - Capture
recoverByKeydurationMsfromwindow.smoke.events().
- recovery returns the originally-created account
- loaded recovered account's state commitment matches on-chain commitment
events()shows a successfulrecoverByKeyentry with non-zero duration
matches.length === 0after step 5 succeeded (lookup auth or routing regression — distinct from "skipped step 5" where empty is expected).- thrown auth/unauthenticated error (proof-of-possession metadata regression)
- recovered
accountIddiffers fromoriginalAccountId(server-side index regression) verifyStateCommitmentmismatch after recover + load (state-fetch regression)