Skip to content

Latest commit

 

History

History
460 lines (363 loc) · 20.1 KB

File metadata and controls

460 lines (363 loc) · 20.1 KB

Workflow Matrix

Use This File

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.

Environment

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 dev

Default 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
  • one examples/smoke-web dev server at http://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_mcp tabs count as one profile, not three — see Browser Automation in SKILL.md)
  • Miden RPC matching the chosen target:
    • Local dev or Staging: https://rpc.devnet.miden.io
    • Production: https://rpc.testnet.miden.io
  • 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.

browser-baseline

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:

  1. Start GUARDIAN and examples/smoke-web.
  2. Open browser/profile A, B, and C at the active smoke harness URL.
  3. Run initSession in each browser with unique browserLabel.
  4. Capture each browser's commitment from status().
  5. In browser A, create the multisig using B and C commitments:
    await window.smoke.createAccount({
      threshold: 2,
      otherCommitments: ['0x...', '0x...'],
    });
  6. Capture the accountId from status().multisig.accountId in browser A.
  7. 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

online-proposal-canary

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:

  1. Start from the baseline harness with browsers A, B, and C.
  2. Create the initial multisig with A and B only.
  3. Keep C unbound to the account so its commitment can be added later.

Steps:

  1. In A, create the add-signer proposal:
    const created = await window.smoke.createProposal({
      type: 'add_signer',
      commitment: '0xC_COMMITMENT',
      increaseThreshold: false,
    });
  2. In B, sync if needed, then sign:
    await window.smoke.sync();
    await window.smoke.signProposal({ proposalId: created.proposal.id });
  3. 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 });
  4. In A or B, execute:
    await window.smoke.executeProposal({ proposalId: created.proposal.id });
  5. 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-execute sync() nonce-overwrite the same way.
  6. 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() or loadAccount() 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

payment-roundtrip-canary

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:

  1. Start two browsers, A and B.
  2. Create a 2-of-2 account and load it in both browsers.
  3. Record the accountId from status().multisig.accountId.

Steps:

  1. Open Miden Faucet.
  2. Paste the accountId into Recipient address and send a public note.
  3. In A and B, poll sync() and listConsumableNotes() until the note appears.
  4. 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],
    });
  5. In B, sign the proposal. Then execute from A or B.
  6. 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.
  7. Create a self-addressed P2ID proposal using the same accountId and 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,
    });
  8. Sign and execute the P2ID proposal.
  9. 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

private-note-roundtrip-canary

Use when:

  • private P2ID note behavior or note export/import changed (issues #322, #356)
  • exportNote / importNote / getP2idNoteId harness commands changed
  • the prompt asks to validate out-of-band note transfer or private note consumption

Setup:

  1. Run the payment-roundtrip-canary setup through vault funding: browsers A and B on one 2-of-2 account, faucet receipt consumed so the vault holds an asset.

Steps:

  1. 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',
    });
  2. 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 });
  3. In B, sign the proposal. In A, execute it.
  4. In B, sync() and confirm listConsumableNotes() does NOT show the private note (only its commitment is on chain; B has a separate local store).
  5. In A, export the note file:
    const { noteFileBase64 } = await window.smoke.exportNote({ noteId });
  6. Hand the base64 string to B out-of-band (copy between consoles) and import it there:
    const imported = await window.smoke.importNote({ noteFileBase64 });
    importNote syncs afterwards; the note should appear in imported.status.consumableNotes.
  7. In B, create a consume-notes proposal with the imported note ID.
  8. In A, sync() until the note appears in listConsumableNotes() 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 must importNote first.)
  9. Execute, sync both browsers, and verify the vault balance reflects the reconsumed asset.

Expect:

  • the proposal metadata carries noteType: 'private'
  • getP2idNoteId returns the same ID the export later resolves
  • before import, B cannot see the private note via sync
  • exportNote returns non-empty base64 and importNote in 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 exportNote fails 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 (or importNote) 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

switch-guardian-offline-canary

Use when:

  • the prompt asks to switch GUARDIAN providers
  • Switch GUARDIAN transaction 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.1 or all localhost
  • the current smoke harness does not expose the active post-switch GUARDIAN endpoint in status(), so verification must be behavior-based

Setup:

  1. Start GUARDIAN A on the default ports.
  2. Temporarily change crates/server/src/main.rs so GUARDIAN B uses alternate HTTP and gRPC ports.
  3. Start GUARDIAN B with distinct storage directories.
  4. Fetch GUARDIAN B's commitment from its HTTP endpoint. Match the query to the active signer scheme:
    curl http://127.0.0.1:3001/pubkey
    For ECDSA runs, use:
    curl 'http://127.0.0.1:3001/pubkey?scheme=ecdsa'
  5. Start browsers A and B against GUARDIAN A.
  6. Create a 2-of-2 multisig and load it in both browsers.
  7. Kill GUARDIAN A.

Steps:

  1. 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',
    });
  2. Export it:
    const exported = await window.smoke.exportProposal({ proposalId: created.proposal.id });
  3. 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 });
  4. 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 });
  5. Execute the fully-signed proposal from A.
  6. 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

miden-wallet-connectivity

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:

  1. Initialize the session in a clean browser with the extension installed.
  2. Run:
    await window.smoke.connectMidenWallet();
    const status = await window.smoke.status();
  3. Verify status.signerSource === 'miden-wallet' and status.midenWallet.connected === true.
  4. If the trigger was ECDSA external signer resolution changed, also verify status.midenWallet.scheme === 'ecdsa'; a Falcon-only wallet run does not exercise external ECDSA resolution and does not satisfy that trigger.
  5. 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.scheme is ecdsa
  • any requested signing path succeeds with the wallet as the active signer source

state-verification

Use when:

  • commitment verification changed
  • sync-after-execute behavior changed
  • the prompt explicitly asks to compare local vs on-chain state

Steps:

  1. Load a multisig and run sync().
  2. Fetch decoded state:
    await window.smoke.fetchState();
  3. Verify commitments:
    await window.smoke.verifyStateCommitment();
  4. 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

recover-by-key-canary

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).

Notes before running

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.

Steps

  1. Start GUARDIAN and examples/smoke-web.
  2. Browser A: page-load bootstrap reaches ready. Capture status().localSigners.falconCommitment as coldCommitment.
  3. Browser B: bootstrap, capture commitment.
  4. Browser A:
    await window.smoke.createAccount({
      threshold: 2,
      otherCommitments: ['<browser-B commitment>'],
    });
    Capture status().multisig.accountId as originalAccountId.
  5. Browser A — register the account with GUARDIAN. Required; without this, GUARDIAN has no authorization record for coldCommitment and step 6 will return []:
    await window.smoke.registerOnGuardian();
  6. Browser A (same session — keys still in memory):
    const matches = await window.smoke.recoverByKey();
    Assert: matches.length >= 1 and at least one entry's accountId equals originalAccountId.
  7. Browser A: paste originalAccountId into the Account ID field, run await window.smoke.loadAccount({ accountId: originalAccountId }), then sync, then verifyStateCommitment. Assert: localCommitment === onChainCommitment.
  8. Capture recoverByKey durationMs from window.smoke.events().

Pass criteria

  • recovery returns the originally-created account
  • loaded recovered account's state commitment matches on-chain commitment
  • events() shows a successful recoverByKey entry with non-zero duration

Failure indicators

  • matches.length === 0 after 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 accountId differs from originalAccountId (server-side index regression)
  • verifyStateCommitment mismatch after recover + load (state-fetch regression)