Skip to content

feat: enable account address resolution to support dApps connectivity#564

Open
baptiste-marchand wants to merge 14 commits intomainfrom
feat/bitcoin-address-resolution
Open

feat: enable account address resolution to support dApps connectivity#564
baptiste-marchand wants to merge 14 commits intomainfrom
feat/bitcoin-address-resolution

Conversation

@baptiste-marchand
Copy link

@baptiste-marchand baptiste-marchand commented Nov 14, 2025

Implements account address resolution for Bitcoin requests, allowing MetaMask to route non-EVM dapp signing requests correctly. This is a requirement for Bitcoin dApps connectivity

  • Introduces the resolveAccountAddress method in KeyringHandler and KeyringRequestHandler to determine the account address for signing requests.
  • Adds necessary data structures and validation logic for Bitcoin request parameters.

Note

Medium Risk
Touches keyring routing and transaction signing/sending flows, and introduces new user-confirmation gating that can block or misroute requests if validation or UI flows are incorrect.

Overview
Enables MetaMask to route incoming Bitcoin dApp requests by adding KeyringRpcMethod.ResolveAccountAddress handling in KeyringHandler, validating requests via a new BtcWalletRequestStruct, and resolving the correct CAIP-10 account based on scope + params.account.address (returning null on failure).

Tightens Bitcoin request schemas to require an account: { address } param across keyring methods, and wires in user confirmations for SignPsbt and SendTransfer: KeyringRequestHandler now blocks on JSXConfirmationRepository.insertSignPsbt, while AccountUseCases.sendTransfer is refactored to enforce exactly one recipient, build a PSBT with fee rate + frozen UTXOs, and prompt a unified send confirmation before signing/broadcasting.

Adds a new SignPsbtConfirmationView UI (outputs/options/fees + optional fiat rate), plumbs origin into unified send confirmations, expands i18n strings, and updates unit/integration tests and manifest shasum to match the new request/confirmation flow.

Written by Cursor Bugbot for commit 7fc750e. This will update automatically on new commits. Configure here.

@baptiste-marchand baptiste-marchand requested a review from a team as a code owner November 14, 2025 17:40
@baptiste-marchand baptiste-marchand changed the title feat: enables account address resolution to support dApps connectivity feat: enable account address resolution to support dApps connectivity Nov 14, 2025
@baptiste-marchand baptiste-marchand force-pushed the feat/bitcoin-address-resolution branch from a041ba7 to e0ea92e Compare November 17, 2025 15:19
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Account Field Mandate Breaks Tests

Existing unit tests create request params without the required account field, but the request struct changes now mandate this field. Tests for signPsbt, computeFee, fillPsbt, broadcastPsbt, sendTransfer, getUtxo, and signMessage will fail during assertion validation because their params lack the required account: { address: string } object.

packages/snap/src/handlers/KeyringRequestHandler.test.ts#L64-L431

const mockRequest = mock<KeyringRequest>({
origin,
request: {
method: AccountCapability.SignPsbt,
params: {
psbt: 'psbtBase64',
feeRate: 3,
options: mockOptions,
},
},
account: 'account-id',
});
it('executes signPsbt', async () => {
mockAccountsUseCases.signPsbt.mockResolvedValue({
psbt: 'psbtBase64',
txid: mock<Txid>({
toString: jest.fn().mockReturnValue('txid'),
}),
});
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
SignPsbtRequest,
);
expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith(
'account-id',
mockPsbt,
'metamask',
mockOptions,
3,
);
expect(result).toStrictEqual({
pending: false,
result: { psbt: 'psbtBase64', txid: 'txid' },
});
});
it('propagates errors from parsePsbt', async () => {
const error = new Error('parsePsbt');
jest.mocked(parsePsbt).mockImplementationOnce(() => {
throw error;
});
await expect(
handler.route({
...mockRequest,
request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } },
}),
).rejects.toThrow(error);
expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled();
});
it('propagates errors from signPsbt', async () => {
const error = new Error();
mockAccountsUseCases.signPsbt.mockRejectedValue(error);
await expect(handler.route(mockRequest)).rejects.toThrow(error);
expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled();
});
});
describe('computeFee', () => {
const mockRequest = mock<KeyringRequest>({
request: {
method: AccountCapability.ComputeFee,
params: {
psbt: 'psbtBase64',
feeRate: 3,
},
},
account: 'account-id',
});
it('executes computeFee', async () => {
mockAccountsUseCases.computeFee.mockResolvedValue(
mock<Amount>({
// eslint-disable-next-line @typescript-eslint/naming-convention
to_sat: () => BigInt(1000),
}),
);
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
ComputeFeeRequest,
);
expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith(
'account-id',
mockPsbt,
3,
);
expect(result).toStrictEqual({
pending: false,
result: { fee: '1000' },
});
});
it('propagates errors from parsePsbt', async () => {
const error = new Error('parsePsbt');
jest.mocked(parsePsbt).mockImplementationOnce(() => {
throw error;
});
await expect(
handler.route({
...mockRequest,
request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } },
}),
).rejects.toThrow(error);
expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled();
});
it('propagates errors from computeFee', async () => {
const error = new Error();
mockAccountsUseCases.computeFee.mockRejectedValue(error);
await expect(handler.route(mockRequest)).rejects.toThrow(error);
expect(mockAccountsUseCases.computeFee).toHaveBeenCalled();
});
});
describe('fillPsbt', () => {
const mockRequest = mock<KeyringRequest>({
request: {
method: AccountCapability.FillPsbt,
params: {
psbt: 'psbtBase64',
feeRate: 3,
},
},
account: 'account-id',
});
it('executes fillPsbt', async () => {
const mockFilledPsbt = mock<Psbt>({
toString: jest.fn().mockReturnValue('filledPsbtBase64'),
});
mockAccountsUseCases.fillPsbt.mockResolvedValue(mockFilledPsbt);
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
FillPsbtRequest,
);
expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith(
'account-id',
mockPsbt,
3,
);
expect(result).toStrictEqual({
pending: false,
result: { psbt: 'filledPsbtBase64' },
});
});
it('propagates errors from parsePsbt', async () => {
const error = new Error('parsePsbt');
jest.mocked(parsePsbt).mockImplementationOnce(() => {
throw error;
});
await expect(
handler.route({
...mockRequest,
request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } },
}),
).rejects.toThrow(error);
expect(mockAccountsUseCases.fillPsbt).not.toHaveBeenCalled();
});
it('propagates errors from fillPsbt', async () => {
const error = new Error();
mockAccountsUseCases.fillPsbt.mockRejectedValue(error);
await expect(handler.route(mockRequest)).rejects.toThrow(error);
expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalled();
});
});
describe('broadcastPsbt', () => {
const mockRequest = mock<KeyringRequest>({
origin,
request: {
method: AccountCapability.BroadcastPsbt,
params: {
psbt: 'psbtBase64',
feeRate: 3,
},
},
account: 'account-id',
});
it('executes broadcastPsbt', async () => {
const mockTxid = mock<Txid>({
toString: jest.fn().mockReturnValue('txid'),
});
mockAccountsUseCases.broadcastPsbt.mockResolvedValue(mockTxid);
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
BroadcastPsbtRequest,
);
expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalledWith(
'account-id',
mockPsbt,
origin,
);
expect(result).toStrictEqual({
pending: false,
result: { txid: 'txid' },
});
});
it('propagates errors from parsePsbt', async () => {
const error = new Error('parsePsbt');
jest.mocked(parsePsbt).mockImplementationOnce(() => {
throw error;
});
await expect(
handler.route({
...mockRequest,
request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } },
}),
).rejects.toThrow(error);
expect(mockAccountsUseCases.broadcastPsbt).not.toHaveBeenCalled();
});
it('propagates errors from fillPsbt', async () => {
const error = new Error();
mockAccountsUseCases.broadcastPsbt.mockRejectedValue(error);
await expect(handler.route(mockRequest)).rejects.toThrow(error);
expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalled();
});
});
describe('sendTransfer', () => {
const recipients = [
{
address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz',
amount: '1000',
},
];
const mockRequest = mock<KeyringRequest>({
origin,
request: {
method: AccountCapability.SendTransfer,
params: {
recipients,
feeRate: 3,
},
},
account: 'account-id',
});
it('executes sendTransferq', async () => {
const mockTxid = mock<Txid>({
toString: jest.fn().mockReturnValue('txid'),
});
mockAccountsUseCases.sendTransfer.mockResolvedValue(mockTxid);
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
SendTransferRequest,
);
expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalledWith(
'account-id',
recipients,
origin,
3,
);
expect(result).toStrictEqual({
pending: false,
result: { txid: 'txid' },
});
});
it('propagates errors from sendTransfer', async () => {
const error = new Error();
mockAccountsUseCases.sendTransfer.mockRejectedValue(error);
await expect(handler.route(mockRequest)).rejects.toThrow(error);
expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalled();
});
});
describe('getUtxo', () => {
const mockLocalOutput = mock<LocalOutput>();
const mockAccount = mock<BitcoinAccount>({
getUtxo: () => mockLocalOutput,
network: 'bitcoin',
});
const mockRequest = mock<KeyringRequest>({
origin,
request: {
method: AccountCapability.GetUtxo,
params: {
outpoint: 'mytxid:0',
},
},
account: 'account-id',
});
it('executes getUtxo', async () => {
const expectedUtxo = {
derivationIndex: 0,
outpoint: 'mytxid:0',
value: '1000',
scriptPubkey: 'scriptPubkey',
scriptPubkeyHex: 'scriptPubkeyHex',
};
mockAccountsUseCases.get.mockResolvedValue(mockAccount);
jest.mocked(mapToUtxo).mockReturnValue(expectedUtxo);
const result = await handler.route(mockRequest);
expect(assert).toHaveBeenCalledWith(
mockRequest.request.params,
GetUtxoRequest,
);
expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id');
expect(result).toStrictEqual({
pending: false,
result: expectedUtxo,
});
});
});
describe('listUtxos', () => {
const mockLocalOutput = mock<LocalOutput>();
const mockAccount = mock<BitcoinAccount>({
listUnspent: () => [mockLocalOutput, mockLocalOutput],
network: 'bitcoin',
});
const mockRequest = mock<KeyringRequest>({
origin,
request: {
method: AccountCapability.ListUtxos,
},
account: 'account-id',
});
it('executes listUtxos', async () => {
const mockUtxo = mock<Utxo>({
derivationIndex: 0,
outpoint: 'mytxid:0',
value: '1000',
scriptPubkey: 'scriptPubkey',
scriptPubkeyHex: 'scriptPubkeyHex',
});

Fix in Cursor Fix in Web


@baptiste-marchand baptiste-marchand force-pushed the feat/bitcoin-address-resolution branch from 105bfe9 to 62f251f Compare December 9, 2025 16:08
default: {
throw new Error('Unsupported method');
}
}
Copy link

Choose a reason for hiding this comment

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

Redundant switch in resolveAccountAddress method

Low Severity

The switch statement in resolveAccountAddress is entirely redundant — every BtcMethod case performs the identical operation: extracting request.params.account.address. Since every variant of BtcWalletRequestStruct includes account: WalletAccountStruct in its params, TypeScript allows accessing request.params.account.address directly on the union type without narrowing. The whole switch (and unreachable default branch) can be replaced with a single line: const addressToValidate = request.params.account.address.

Fix in Cursor Fix in Web


const result = await response;

expect(result).toRespondWith({
Copy link

Choose a reason for hiding this comment

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

Promise used as response in test

High Severity

In the sendTransfer integration test, snap.onKeyringRequest(...) is no longer awaited, but the code calls response.getInterface() as if response were the resolved response object. If onKeyringRequest returns a Promise, this becomes a runtime failure and breaks the test flow/CI.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

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.

3 participants