Skip to content
Open
5 changes: 3 additions & 2 deletions packages/wallet-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Auto-accept pending approval requests in the daemon so a transaction or signature flow resolves instead of hanging on the headless no-op `showApprovalRequest`; the subscription is torn down on `dispose` ([#9612](https://github.com/MetaMask/core/pull/9612))
- **Security note:** the daemon approves every request without a per-request prompt; the trust boundary is its `0600`, same-user Unix socket.
- Wire the `transactionController` slot in the daemon wallet's instance options, so the daemon runs the `TransactionController` with an explicit CLI-appropriate configuration (swaps processing disabled, no client hooks) rather than relying on the controller's implicit defaults ([#9509](https://github.com/MetaMask/core/pull/9509))
- Wire the `gasFeeController` slot in the daemon wallet's instance options, passing `clientId: 'cli'` so the CLI identifies itself to the gas estimation API, now that `@metamask/wallet` requires this option ([#9527](https://github.com/MetaMask/core/pull/9527))
- Add the `mm wallet unlock` command, which dispatches `KeyringController:submitPassword` over the daemon socket, allowing the keyring to be unlocked after a daemon start with no password or after a `mm daemon call KeyringController:setLocked` ([#8821](https://github.com/MetaMask/core/pull/8821))
Expand All @@ -25,8 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `--password` / `MM_WALLET_PASSWORD` is now optional on `mm daemon start`; on subsequent runs, omitting it starts the daemon with a locked keyring, and the persisted vault is auto-unlocked when a password is supplied ([#8821](https://github.com/MetaMask/core/pull/8821))
- The daemon RPC server now validates `params` against each handler's superstruct before dispatch, returning a `-32602 invalidParams` error on mismatch instead of passing raw params to the handler ([#8846](https://github.com/MetaMask/core/pull/8846))
- Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339))
- Bump `@metamask/wallet` from `^3.0.0` to `^8.1.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9629](https://github.com/MetaMask/core/pull/9629))
- Bump `@metamask/wallet` from `^3.0.0` to `^8.1.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9609](https://github.com/MetaMask/core/pull/9609), [#9629](https://github.com/MetaMask/core/pull/9629))
- Wrap daemon password and SRP in opaque `Password` and `Srp` types that redact on logging; validated and unwrapped only at trust boundaries ([#8863](https://github.com/MetaMask/core/pull/8863))
- Bump `@metamask/wallet` from `^7.0.1` to `8.0.0`. ([#9609](https://github.com/MetaMask/core/pull/9609))

[Unreleased]: https://github.com/MetaMask/core/
2 changes: 2 additions & 0 deletions packages/wallet-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ mm daemon purge # stop, then delete all daemon state files (--force to

State (socket, PID file, log, and the SQLite database) lives in the per-user oclif data directory; override it with `MM_DATA_DIR`.

> **Security model — the daemon auto-approves everything.** Because it is headless, the daemon accepts every approval request (transactions and signatures included) with no per-request prompt; otherwise an awaited request would hang forever with no UI to resolve it. The trust boundary is therefore the daemon's `0600`, same-user Unix socket — anything that can reach the socket can move funds. A scoped/opt-in approval policy is planned for when a user-facing send command lands.

## Troubleshooting

### Rebuilding `better-sqlite3`
Expand Down
191 changes: 191 additions & 0 deletions packages/wallet-cli/src/daemon/auto-approval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { subscribeToAutoApproval } from './auto-approval.js';

type ApprovalStateChangeHandler = (state: {
pendingApprovals: Record<string, unknown>;
}) => void;

type MessengerArg = Parameters<typeof subscribeToAutoApproval>[0];

/**
* Build a fake root messenger that records `subscribe`/`unsubscribe`/`call` and
* captures the state-change handler `subscribeToAutoApproval` registers, so a
* test can drive it directly.
*
* @returns The fake messenger, its jest mocks, and a `handler` getter.
*/
function makeMessenger(): {
messenger: MessengerArg;
call: jest.Mock;
subscribe: jest.Mock;
unsubscribe: jest.Mock;
handler: () => ApprovalStateChangeHandler;
} {
let captured: ApprovalStateChangeHandler | undefined;
const call = jest.fn().mockResolvedValue({ value: undefined });
const subscribe = jest.fn(
(_eventType: string, subscribed: ApprovalStateChangeHandler) => {
captured = subscribed;
},
);
const unsubscribe = jest.fn();

return {
messenger: { call, subscribe, unsubscribe } as unknown as MessengerArg,
call,
subscribe,
unsubscribe,
handler: (): ApprovalStateChangeHandler => {
if (!captured) {
throw new Error('handler was not registered');
}
return captured;
},
};
}

/**
* Drain pending microtasks so the `.catch(...).finally(...)` chain attached to
* each accept settles before assertions run. Uses `setImmediate` to cross the
* I/O callback boundary, which flushes the entire microtask queue regardless
* of chain depth.
*/
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}

describe('subscribeToAutoApproval', () => {
it('subscribes to ApprovalController:stateChanged', () => {
const { messenger, subscribe } = makeMessenger();

subscribeToAutoApproval(messenger);

expect(subscribe).toHaveBeenCalledTimes(1);
expect(subscribe).toHaveBeenCalledWith(
'ApprovalController:stateChanged',
expect.any(Function),
);
});

it('accepts every pending request via ApprovalController:acceptRequest', () => {
const { messenger, call, handler } = makeMessenger();
subscribeToAutoApproval(messenger);

handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } });

expect(call).toHaveBeenCalledWith(
'ApprovalController:acceptRequest',
'id-a',
);
expect(call).toHaveBeenCalledWith(
'ApprovalController:acceptRequest',
'id-b',
);
expect(call).toHaveBeenCalledTimes(2);
});

it('does nothing when there are no pending requests', () => {
const { messenger, call, handler } = makeMessenger();
subscribeToAutoApproval(messenger);

handler()({ pendingApprovals: {} });

expect(call).not.toHaveBeenCalled();
});

it('accepts an id only once while its accept is in flight, and again after it settles', async () => {
const { messenger, call, handler } = makeMessenger();
subscribeToAutoApproval(messenger);

handler()({ pendingApprovals: { 'id-a': {} } });
handler()({ pendingApprovals: { 'id-a': {} } });
expect(call).toHaveBeenCalledTimes(1);

await flushMicrotasks();
handler()({ pendingApprovals: { 'id-a': {} } });
expect(call).toHaveBeenCalledTimes(2);
});

it('does not re-accept ids surfaced by the re-entrant state change that accepting itself triggers', () => {
const { messenger, call, handler } = makeMessenger();
subscribeToAutoApproval(messenger);
call.mockImplementation((_action: string, id: string) => {
if (id === 'id-a') {
handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } });
}
return Promise.resolve({ value: undefined });
});

handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } });

expect(call.mock.calls.filter(([, id]) => id === 'id-a')).toHaveLength(1);
expect(call.mock.calls.filter(([, id]) => id === 'id-b')).toHaveLength(1);
});

it('logs and swallows an async rejection from an accept', async () => {
const { messenger, call, handler } = makeMessenger();
call.mockRejectedValueOnce(new Error('accept boom'));
const log = jest.fn();
subscribeToAutoApproval(messenger, log);

expect(() => handler()({ pendingApprovals: { 'id-a': {} } })).not.toThrow();
await flushMicrotasks();

expect(log).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to auto-accept approval request id-a: Error: accept boom',
),
);

// Verify .finally() ran so the id is retryable after an async rejection.
handler()({ pendingApprovals: { 'id-a': {} } });
expect(call).toHaveBeenCalledTimes(2);
});

it('logs and swallows a synchronous throw from an accept, and can retry the id later', () => {
const { messenger, call, handler } = makeMessenger();
call.mockImplementationOnce(() => {
throw new Error('sync boom');
});
const log = jest.fn();
subscribeToAutoApproval(messenger, log);

expect(() => handler()({ pendingApprovals: { 'id-a': {} } })).not.toThrow();
expect(log).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to auto-accept approval request id-a: Error: sync boom',
),
);

handler()({ pendingApprovals: { 'id-a': {} } });
expect(call).toHaveBeenCalledTimes(2);
});

it('falls back to console.error when no logger is supplied', async () => {
const { messenger, call, handler } = makeMessenger();
call.mockRejectedValueOnce(new Error('accept boom'));
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
subscribeToAutoApproval(messenger);

handler()({ pendingApprovals: { 'id-a': {} } });
await flushMicrotasks();

expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to auto-accept approval request id-a'),
);
});

it('unsubscribes the same handler it subscribed', () => {
const { messenger, subscribe, unsubscribe } = makeMessenger();

const dispose = subscribeToAutoApproval(messenger);
dispose();

const [, subscribedHandler] = subscribe.mock.calls[0];
expect(unsubscribe).toHaveBeenCalledWith(
'ApprovalController:stateChanged',
subscribedHandler,
);
});
});
127 changes: 127 additions & 0 deletions packages/wallet-cli/src/daemon/auto-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type {
DefaultActions,
DefaultEvents,
RootMessenger,
} from '@metamask/wallet';

import type { Logger } from './types.js';

/**
* The slice of `ApprovalController` state this module reads. The full
* state-change payload is the controller's `ApprovalControllerState`; auto
* approval only needs the ids of the pending requests, so it looks at
* `pendingApprovals` alone (the request bodies are irrelevant to accepting
* them).
*/
type PendingApprovalsState = {
pendingApprovals: Record<string, unknown>;
};

/**
* Handler for `ApprovalController`'s state-change event, narrowed to the slice
* auto approval reads. The `Patch[]` second argument from the raw event is
* intentionally absent — auto approval re-accepts on every state change and
* does not filter by patch.
*/
type ApprovalStateChangeHandler = (state: PendingApprovalsState) => void;

/**
* Subscribe the daemon to auto-accept every pending approval request.
*
* Without this, any `ApprovalController:addRequest` call hangs forever on a
* headless daemon. **Everything is approved without a prompt** — trust boundary
* is the `0600` same-user socket. A scoped policy is tracked in
* {@link https://github.com/MetaMask/core/issues/9513}.
*
* The `inFlight` guard makes accepting each id idempotent: `acceptRequest`
* deletes the request on resolve, which itself re-emits `stateChanged`. Without
* it the same id would be accepted twice and the second accept would reject.
* Both sync throws and async rejections are logged and swallowed so one bad
* request cannot crash the daemon or wedge the subscription.
*
* @param messenger - The wallet root messenger.
* @param log - Optional logger for accept failures. Defaults to `console.error`.
* @returns A function that unsubscribes the auto-approval handler.
*/
export function subscribeToAutoApproval(
messenger: Readonly<RootMessenger<DefaultActions, DefaultEvents>>,
log?: Logger,
): () => void {
const logFn =
log ??
((message: string): void => {
console.error(message);
});

const inFlight = new Set<string>();

const logFailure = (id: string, error: unknown): void => {
logFn(`Failed to auto-accept approval request ${id}: ${String(error)}`);
};

const acceptRequest = (id: string): void => {
if (inFlight.has(id)) {
return;
}
inFlight.add(id);

try {
messenger
.call('ApprovalController:acceptRequest', id)
.catch((error: unknown) => logFailure(id, error))
.finally(() => inFlight.delete(id));
} catch (error) {
// Synchronous throw means the Promise chain above was never built
// (no .catch/.finally attached), so clean up the in-flight guard here.
// The error is intentionally suppressed — a race-condition reject must
// not crash the daemon or permanently wedge the subscription.
inFlight.delete(id);
logFailure(id, error);
}
};

const handler: ApprovalStateChangeHandler = (state) => {
for (const id of Object.keys(state.pendingApprovals)) {
acceptRequest(id);
}
};

return subscribeToApprovalStateChanged(messenger, handler);
}

/**
* Subscribe a handler to `ApprovalController:stateChanged`.
*
* `ApprovalControllerEvents` only declares `ApprovalController:stateChange`
* (via `ControllerStateChangeEvent`), not the non-deprecated
* `ApprovalController:stateChanged` variant that `BaseController` publishes at
* runtime. This helper localizes the unavoidable cast — using the same
* technique as `subscribeToStateChanged` in the persistence layer — behind a
* typed {@link ApprovalStateChangeHandler}, keeping the `state` payload
* compile-checked at the call site instead of erased by a statement-level
* `@ts-expect-error`.
*
* TODO: Remove this cast once `ApprovalControllerEvents` includes
* `ControllerStateChangedEvent` (`:stateChanged`), matching the union already
* exported by `BaseController`.
*
* @param messenger - The wallet root messenger.
* @param handler - The state-change handler to register.
* @returns A function that unsubscribes the handler.
*/
function subscribeToApprovalStateChanged(
messenger: Readonly<RootMessenger<DefaultActions, DefaultEvents>>,
handler: ApprovalStateChangeHandler,
): () => void {
const subscriber = messenger as unknown as {
subscribe: (eventType: string, handler: ApprovalStateChangeHandler) => void;
unsubscribe: (
eventType: string,
handler: ApprovalStateChangeHandler,
) => void;
};
subscriber.subscribe('ApprovalController:stateChanged', handler);
return () => {
subscriber.unsubscribe('ApprovalController:stateChanged', handler);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ describe('createWallet (real Wallet, in-memory)', () => {
await dispose();
}
});

// Short timeout: auto-approval must resolve in milliseconds once the wallet
// is up. A regression hangs, so fail fast rather than waiting the file-wide
// 30 s (set for KDF/SQLite-heavy tests).
it('auto-accepts a pending approval request instead of hanging', async () => {
const { wallet, dispose } = await createWallet({
databasePath: ':memory:',
password: Password.from(TEST_PASSWORD),
srp: Srp.from(TEST_PHRASE),
infuraProjectId: INFURA_PROJECT_ID,
log: () => undefined,
});

try {
// `shouldShowRequest: false` bypasses the no-op `showApprovalRequest` hook —
// acceptance must come purely from the auto-approval subscription.
const accepted = await wallet.messenger.call(
'ApprovalController:addRequest',
{ origin: 'https://example.com', type: 'test:approval' },
false,
);
expect(accepted).toBeUndefined();

expect(
wallet.messenger.call('ApprovalController:getState').pendingApprovals,
).toStrictEqual({});
} finally {
await dispose();
}
}, 5_000);
});

describe('createWallet (integration)', () => {
Expand Down
Loading