Compare commits
2
Commits
0bba6ae084
...
18188c2f62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18188c2f62 | ||
|
|
99292b9188 |
@@ -45,6 +45,32 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-21: A transaction response is honoured only for a transaction
|
||||||
|
approval, and the three remaining approval-settlement paths are pinned
|
||||||
|
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
|
||||||
|
the issue asks for — settle `4001` on release when the window it would be
|
||||||
|
retried in is gone — already landed with
|
||||||
|
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
|
||||||
|
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
|
||||||
|
approval, so a reject no longer retires a sign or connection approval and a
|
||||||
|
signed artifact never runs the broadcast path against one, which before only
|
||||||
|
failed closed by throwing deeper in. Tests pin the site-connection port's
|
||||||
|
approve, reject and disconnect paths against a transaction approval
|
||||||
|
broadcasting behind them: each is declined and the dApp still receives its
|
||||||
|
broadcast result.
|
||||||
|
|
||||||
|
- 2026-09-21: Adding a second wallet no longer accepts a different password with
|
||||||
|
nothing saying it is a separate one
|
||||||
|
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
|
||||||
|
own encrypted secret, so per-wallet passwords are by design; the add-wallet
|
||||||
|
screen said only "Choose a password". A note now appears on that screen when
|
||||||
|
the profile already holds a wallet, stating that each wallet has its own
|
||||||
|
password and this one need not match any already in use. It is shown only
|
||||||
|
then, since the first wallet has no other password to differ from, and it
|
||||||
|
stays consistent with the no-reset reality of
|
||||||
|
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
|
||||||
|
recovery or reset.
|
||||||
|
|
||||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
||||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
||||||
wallet list was the reported case: the address shared one row with the
|
wallet list was the reported case: the address shared one row with the
|
||||||
|
|||||||
@@ -1344,6 +1344,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (!approval) return false;
|
if (!approval) return false;
|
||||||
|
|
||||||
|
// This message signs and broadcasts a transaction, so it is honoured
|
||||||
|
// only for a transaction approval. A sign or connection approval
|
||||||
|
// carries no approvedTx, and reaching the broadcast path with one used
|
||||||
|
// to fail closed by throwing deeper in; refusing here keeps a future
|
||||||
|
// refactor from turning that incidental throw into a live path, and
|
||||||
|
// keeps a reject on this message from retiring an approval of another
|
||||||
|
// kind.
|
||||||
|
if (approval.type !== "tx") return false;
|
||||||
|
|
||||||
// A reject arriving while an attempt holds the approval is refused,
|
// A reject arriving while an attempt holds the approval is refused,
|
||||||
// not honoured: the attempt is on its way to broadcasting the
|
// not honoured: the attempt is on its way to broadcasting the
|
||||||
// transaction, and resolving 4001 here would tell the page the request
|
// transaction, and resolving 4001 here would tell the page the request
|
||||||
|
|||||||
@@ -152,6 +152,21 @@
|
|||||||
|
|
||||||
<!-- Shared password fields -->
|
<!-- Shared password fields -->
|
||||||
<div class="mb-2" id="add-wallet-password-section">
|
<div class="mb-2" id="add-wallet-password-section">
|
||||||
|
<!-- Shown only when the profile already holds a wallet:
|
||||||
|
each wallet has its own password (its own
|
||||||
|
encryptedSecret), so a second wallet does not reuse
|
||||||
|
the first one's. addWallet.js toggles this on screen
|
||||||
|
entry from state.wallets.length, so it is constant
|
||||||
|
while the screen is up and moves nothing. -->
|
||||||
|
<p
|
||||||
|
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
|
||||||
|
id="add-wallet-separate-password-note"
|
||||||
|
>
|
||||||
|
You already have a wallet. Each wallet has its own
|
||||||
|
password: the one you choose here is only for this new
|
||||||
|
wallet, and it need not match any password you already
|
||||||
|
use.
|
||||||
|
</p>
|
||||||
<label class="block mb-1">Choose a password</label>
|
<label class="block mb-1">Choose a password</label>
|
||||||
<!-- The hint is swapped in place when the import tab
|
<!-- The hint is swapped in place when the import tab
|
||||||
changes, and it sits directly above the password
|
changes, and it sits directly above the password
|
||||||
|
|||||||
@@ -100,9 +100,24 @@ function clear() {
|
|||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each wallet has its own password (its own encryptedSecret), so adding a
|
||||||
|
// second wallet does not reuse the first one's. The note that says so is
|
||||||
|
// only meaningful once a wallet exists — on the first wallet there is no
|
||||||
|
// other password to be separate from — so it is shown only then. This is
|
||||||
|
// decided on entry and stays put while the screen is up, so it does not
|
||||||
|
// move the password fields the way a per-tab hint would.
|
||||||
|
function updateSeparatePasswordNote() {
|
||||||
|
const hasExistingWallet = state.wallets.length > 0;
|
||||||
|
$("add-wallet-separate-password-note").classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
!hasExistingWallet,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
clear();
|
clear();
|
||||||
switchMode("mnemonic");
|
switchMode("mnemonic");
|
||||||
|
updateSeparatePasswordNote();
|
||||||
showView("add-wallet");
|
showView("add-wallet");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Adding a second wallet accepts a password different from the first one's
|
||||||
|
// with nothing on screen saying the two are separate — each wallet has its
|
||||||
|
// own encryptedSecret, so per-wallet passwords are by design, but the add
|
||||||
|
// screen said only "Choose a password"
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
|
||||||
|
//
|
||||||
|
// The fix is copy: a note on the password screen that says each wallet has
|
||||||
|
// its own password and this one need not match. It is only meaningful once
|
||||||
|
// a wallet exists — on the very first wallet there is no other password to
|
||||||
|
// be separate from — so it is shown then and hidden otherwise. These boot
|
||||||
|
// the real popup and reach the add-wallet screen through the same button a
|
||||||
|
// user presses, so the note's visibility is decided by the real show().
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
POPUP_HTML,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const NOTE = "add-wallet-separate-password-note";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("second-wallet password note", () => {
|
||||||
|
test("hidden while onboarding the first wallet", async () => {
|
||||||
|
const page = await bootPopup(undefined);
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
await page.click("btn-welcome-add");
|
||||||
|
expect(page.visibleViews()).toContain("add-wallet");
|
||||||
|
expect(page.hidden(NOTE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shown when a wallet already exists", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
await page.click("btn-main-add-wallet");
|
||||||
|
expect(page.visibleViews()).toContain("add-wallet");
|
||||||
|
expect(page.hidden(NOTE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The copy states the two facts the definition of done asks for — each
|
||||||
|
// wallet has its own password, and this one need not match — and stays
|
||||||
|
// consistent with the no-password-reset reality of
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
|
||||||
|
// recovery or reset here.
|
||||||
|
test("the note says the password is per-wallet and need not match", () => {
|
||||||
|
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
|
||||||
|
.exec(POPUP_HTML)[1]
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
expect(note).toContain("its own");
|
||||||
|
expect(note).toContain("need not match");
|
||||||
|
expect(note).not.toMatch(/recover|reset/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1541,6 +1541,149 @@ describe("a claimed approval outlives every other retirement path", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The approval popup connects a port named for its approval whatever the
|
||||||
|
// approval's kind, so a decision or a disconnect on that port can reach a
|
||||||
|
// transaction approval. Both must be declined: the port decides only
|
||||||
|
// site-connection approvals, and settling a transaction approval it does not
|
||||||
|
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
|
||||||
|
// bug, where the page is told the request was rejected as the transaction goes
|
||||||
|
// out. These three paths route through settleApproval() and, before this
|
||||||
|
// suite, were exercised only against site approvals.
|
||||||
|
describe("the site-connection port never retires a transaction approval", () => {
|
||||||
|
async function txMidBroadcast() {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
const inFlight = deferred();
|
||||||
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||||
|
const first = bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id,
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(7),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
return { bg, pending, id, inFlight, first };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an approve on the port does not settle it", async () => {
|
||||||
|
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||||
|
|
||||||
|
bg.connectApproval(id).decide(true, false);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
inFlight.resolve({ hash: "0xfeed" });
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reject on the port does not settle it", async () => {
|
||||||
|
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||||
|
|
||||||
|
bg.connectApproval(id).decide(false, false);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
inFlight.resolve({ hash: "0xfeed" });
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a port disconnect does not settle it", async () => {
|
||||||
|
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
||||||
|
|
||||||
|
bg.connectApproval(id).disconnect();
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
inFlight.resolve({ hash: "0xfeed" });
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
|
||||||
|
// only for a transaction approval. A reject shaped as this message used to
|
||||||
|
// retire a sign or connection approval outright, and an approve carrying a
|
||||||
|
// signed artifact used to run the broadcast path against an approval that names
|
||||||
|
// no transaction, failing closed only by throwing deeper in.
|
||||||
|
describe("a transaction response is honoured only for a transaction approval", () => {
|
||||||
|
test("a reject does not retire a sign approval", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSign();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
// Still live: its own reject settles it.
|
||||||
|
bg.send(
|
||||||
|
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reject does not retire a connection approval", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
// Still live: the port that owns it connects the site.
|
||||||
|
const port = bg.connectApproval(id);
|
||||||
|
port.decide(true, false);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSign();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id,
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(7),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// A handler that throws must still answer. `sendResponse` is the only thing
|
// A handler that throws must still answer. `sendResponse` is the only thing
|
||||||
// that settles the page's window.ethereum.request() promise, so a throw that
|
// that settles the page's window.ethereum.request() promise, so a throw that
|
||||||
// escapes a handler leaves that promise pending forever — no error, no
|
// escapes a handler leaves that promise pending forever — no error, no
|
||||||
|
|||||||
Reference in New Issue
Block a user