diff --git a/packages/snap/integration-test/keyring-request.test.ts b/packages/snap/integration-test/keyring-request.test.ts index fde6972e..41f9eb0f 100644 --- a/packages/snap/integration-test/keyring-request.test.ts +++ b/packages/snap/integration-test/keyring-request.test.ts @@ -167,6 +167,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.GetUtxo, params: { + account: { address: account.address }, outpoint: utxos[0]?.outpoint, }, }, @@ -221,6 +222,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -253,6 +255,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -285,6 +288,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -317,6 +321,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', options: { fill: true, @@ -350,6 +355,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, }, }, @@ -363,6 +369,37 @@ describe('KeyringRequestHandler', () => { stack: expect.anything(), }); }); + + it('fails if missing account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: account -- Expected an object, but received: undefined', + stack: expect.anything(), + }); + }); }); describe('fillPsbt', () => { @@ -382,6 +419,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, }, @@ -409,6 +447,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -444,6 +483,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, }, @@ -471,6 +511,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -507,6 +548,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -533,6 +575,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + account: { address: account.address }, psbt: result.psbt, }, }, @@ -559,6 +602,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -579,7 +623,7 @@ describe('KeyringRequestHandler', () => { describe('sendTransfer', () => { it('sends funds successfully', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -590,15 +634,12 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + account: { address: account.address }, recipients: [ { address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', amount: '1000', }, - { - address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - amount: '1000', - }, ], feeRate: 3, }, @@ -606,7 +647,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { txid: expect.any(String), @@ -626,6 +673,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + account: { address: account.address }, recipients: [{ address: 'notAnAddress', amount: '1000' }], }, }, @@ -654,6 +702,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignMessage, params: { + account: { address: account.address }, message: 'Hello, world!', }, }, diff --git a/packages/snap/locales/en.json b/packages/snap/locales/en.json index a4e7e54a..0542ef89 100644 --- a/packages/snap/locales/en.json +++ b/packages/snap/locales/en.json @@ -213,6 +213,48 @@ }, "confirmation.requestOrigin": { "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" } } } diff --git a/packages/snap/messages.json b/packages/snap/messages.json index d25ad7be..1af59a2c 100644 --- a/packages/snap/messages.json +++ b/packages/snap/messages.json @@ -211,5 +211,47 @@ }, "confirmation.requestOrigin": { "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" } } diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 5f2050c0..3658cba5 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "o9ZJmt7WEmIOXnmPlqmqRbGWztcCkDTKkW2VaBza56E=", + "shasum": "cvYcjJKiNrqoOHaXbU57+C83mgy02GeEjTmteaYU2NI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/entities/confirmation.ts b/packages/snap/src/entities/confirmation.ts index f7f5836b..ce04ae7c 100644 --- a/packages/snap/src/entities/confirmation.ts +++ b/packages/snap/src/entities/confirmation.ts @@ -1,6 +1,8 @@ -import type { Network } from '@metamask/bitcoindevkit'; +import type { Network, Psbt } from '@metamask/bitcoindevkit'; +import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { BitcoinAccount } from './account'; +import type { CurrencyUnit } from './currency'; export type SignMessageConfirmationContext = { message: string; @@ -12,6 +14,32 @@ export type SignMessageConfirmationContext = { origin: string; }; +export type SignPsbtOutput = { + address?: string; + amount: string; + isMine: boolean; + isOpReturn: boolean; +}; + +export type SignPsbtConfirmationContext = { + psbt: string; + account: { + id: string; + address: string; + }; + network: Network; + origin: string; + options: { + fill: boolean; + broadcast: boolean; + }; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + fee?: string; + outputs: SignPsbtOutput[]; + inputCount: number; +}; + export enum ConfirmationEvent { Confirm = 'confirmation-confirm', Cancel = 'confirmation-cancel', @@ -33,4 +61,38 @@ export type ConfirmationRepository = { message: string, origin: string, ): Promise; + + /** + * Inserts a send transfer confirmation interface. + * + * @param account - The account sending the transfer. + * @param psbt - The PSBT of the transfer. + * @param recipient - The recipient of the transfer. + * @param recipient.address - The address of the recipient. + * @param recipient.amount - The amount to send to the recipient. + * @param origin - The origin of the request. + */ + insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise; + + /** + * Inserts a sign PSBT confirmation interface. + * + * @param account - The account to sign the PSBT. + * @param psbt - The PSBT to sign (as Psbt object). + * @param origin - The origin of the request. + * @param options - The sign options (fill, broadcast). + * @param options.fill - Whether to fill the PSBT. + * @param options.broadcast - Whether to broadcast the PSBT. + */ + insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise; }; diff --git a/packages/snap/src/entities/send-flow.ts b/packages/snap/src/entities/send-flow.ts index f8230c4b..f47f28df 100644 --- a/packages/snap/src/entities/send-flow.ts +++ b/packages/snap/src/entities/send-flow.ts @@ -17,6 +17,7 @@ export type ConfirmSendFormContext = { backgroundEventId?: string; locale: string; psbt: string; + origin?: string; }; export type SendFormContext = { diff --git a/packages/snap/src/handlers/KeyringHandler.test.ts b/packages/snap/src/handlers/KeyringHandler.test.ts index b2f0047f..82798106 100644 --- a/packages/snap/src/handlers/KeyringHandler.test.ts +++ b/packages/snap/src/handlers/KeyringHandler.test.ts @@ -14,8 +14,9 @@ import type { KeyringResponse, Transaction as KeyringTransaction, KeyringRequest, + KeyringAccount, } from '@metamask/keyring-api'; -import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -844,4 +845,112 @@ describe('KeyringHandler', () => { expect(result).toStrictEqual(expectedResponse); }); }); + + describe('resolveAccountAddress', () => { + const mockKeyringAccount1 = mock({ + id: 'account-1', + address: 'test123', + scopes: [BtcScope.Regtest], + }); + const mockKeyringAccount2 = mock({ + id: 'account-2', + address: 'test456', + scopes: [BtcScope.Regtest], + }); + + beforeEach(() => { + mockAccounts.list.mockResolvedValue([mockAccount]); + }); + + it('resolves account address successfully', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + const requestWithoutCommonHeader = { + method: request.method, + params: request.params, + }; + + // mockAccounts.list.mockResolvedValue([mockAccount]); + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValueOnce([mockKeyringAccount1, mockKeyringAccount2]); + mockKeyringRequest.resolveAccountAddress.mockReturnValue( + 'bip122:000000000019d6689c085ae165831e93:test123', + ); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(handler.listAccounts).toHaveBeenCalled(); + expect(mockKeyringRequest.resolveAccountAddress).toHaveBeenCalledWith( + [mockKeyringAccount1, mockKeyringAccount2], + BtcScope.Regtest, + requestWithoutCommonHeader, + ); + expect(result).toStrictEqual({ + address: 'bip122:000000000019d6689c085ae165831e93:test123', + }); + }); + + it('returns null on error', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'notfound' }, + psbt: 'psbt', + }, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockImplementation() + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + mockKeyringRequest.resolveAccountAddress.mockImplementation(() => { + throw new Error('Account not found'); + }); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null when request validation fails', async () => { + const invalidRequest = { + id: '1', + jsonrpc: '2.0' as const, + method: 'invalid', + params: {}, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockImplementation() + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + + jest.mocked(assert).mockImplementationOnce(() => { + throw new Error('Invalid request'); + }); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + invalidRequest, + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/packages/snap/src/handlers/KeyringHandler.ts b/packages/snap/src/handlers/KeyringHandler.ts index 14e2b249..c5a0fc76 100644 --- a/packages/snap/src/handlers/KeyringHandler.ts +++ b/packages/snap/src/handlers/KeyringHandler.ts @@ -13,6 +13,7 @@ import { ListAccountsRequestStruct, ListAccountTransactionsRequestStruct, MetaMaskOptionsStruct, + ResolveAccountAddressRequestStruct, SetSelectedAccountsRequestStruct, SubmitRequestRequestStruct, } from '@metamask/keyring-api'; @@ -29,8 +30,9 @@ import type { MetaMaskOptions, DiscoveredAccount, KeyringRequest, + ResolvedAccountAddress, } from '@metamask/keyring-api'; -import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { assert, boolean, @@ -54,9 +56,13 @@ import { caipToAddressType, scopeToNetwork, networkToScope, + NetworkStruct, } from './caip'; import { CronMethod } from './CronHandler'; -import type { KeyringRequestHandler } from './KeyringRequestHandler'; +import { + BtcWalletRequestStruct, + type KeyringRequestHandler, +} from './KeyringRequestHandler'; import { mapToDiscoveredAccount, mapToKeyringAccount, @@ -160,6 +166,13 @@ export class KeyringHandler implements Keyring { await this.setSelectedAccounts(request.params.accounts); return null; } + case `${KeyringRpcMethod.ResolveAccountAddress}`: { + assert(request, ResolveAccountAddressRequestStruct); + return this.resolveAccountAddress( + request.params.scope, + request.params.request, + ); + } default: { throw new InexistentMethodError('Keyring method not supported', { @@ -386,6 +399,44 @@ export class KeyringHandler implements Keyring { }); } + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param scope - Request's scope (CAIP-2). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address that must + * be used to process this signing request, or null if none candidates + * could be found. + */ + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + try { + assert(scope, NetworkStruct); + const { method, params } = request; + + const requestWithoutCommonHeader = { method, params }; + assert(requestWithoutCommonHeader, BtcWalletRequestStruct); + + const allAccounts = await this.listAccounts(); + + const caip10Address = this.#keyringRequest.resolveAccountAddress( + allAccounts, + scope, + requestWithoutCommonHeader, + ); + + return { address: caip10Address }; + } catch (error: unknown) { + this.#logger.error({ error }, 'Error resolving account address'); + return null; + } + } + #extractAddressType(path: string): AddressType { const segments = path.split('/'); if (segments.length < 4) { diff --git a/packages/snap/src/handlers/KeyringRequestHandler.test.ts b/packages/snap/src/handlers/KeyringRequestHandler.test.ts index 5394e688..f2fc52cd 100644 --- a/packages/snap/src/handlers/KeyringRequestHandler.test.ts +++ b/packages/snap/src/handlers/KeyringRequestHandler.test.ts @@ -1,5 +1,6 @@ import type { Txid, Psbt, Amount, LocalOutput } from '@metamask/bitcoindevkit'; -import type { KeyringRequest } from '@metamask/keyring-api'; +import type { KeyringRequest, KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -12,8 +13,9 @@ import { KeyringRequestHandler, SendTransferRequest, SignPsbtRequest, + type BtcWalletRequest, } from './KeyringRequestHandler'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, ConfirmationRepository } from '../entities'; import { AccountCapability } from '../entities'; import type { Utxo } from './mappings'; import { mapToUtxo } from './mappings'; @@ -34,9 +36,18 @@ jest.mock('./mappings', () => ({ describe('KeyringRequestHandler', () => { const mockAccountsUseCases = mock(); + const mockConfirmationRepository = mock(); const origin = 'metamask'; - const handler = new KeyringRequestHandler(mockAccountsUseCases); + const ACCOUNT_ADDRESS = 'test-account-address'; + const accountParam = { + account: { address: ACCOUNT_ADDRESS }, + }; + + const handler = new KeyringRequestHandler( + mockAccountsUseCases, + mockConfirmationRepository, + ); beforeEach(() => { jest.mocked(parsePsbt).mockReturnValue(mockPsbt); @@ -60,11 +71,13 @@ describe('KeyringRequestHandler', () => { describe('signPsbt', () => { const mockOptions = { fill: false, broadcast: true }; + const mockAccount = mock(); const mockRequest = mock({ origin, request: { method: AccountCapability.SignPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, options: mockOptions, @@ -73,7 +86,12 @@ describe('KeyringRequestHandler', () => { account: 'account-id', }); - it('executes signPsbt', async () => { + beforeEach(() => { + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + mockConfirmationRepository.insertSignPsbt.mockResolvedValue(undefined); + }); + + it('executes signPsbt with confirmation', async () => { mockAccountsUseCases.signPsbt.mockResolvedValue({ psbt: 'psbtBase64', txid: mock({ @@ -87,6 +105,13 @@ describe('KeyringRequestHandler', () => { mockRequest.request.params, SignPsbtRequest, ); + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( + mockAccount, + mockPsbt, + 'metamask', + mockOptions, + ); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( 'account-id', mockPsbt, @@ -100,6 +125,18 @@ describe('KeyringRequestHandler', () => { }); }); + it('does not sign if user cancels confirmation', async () => { + mockConfirmationRepository.insertSignPsbt.mockRejectedValue( + new Error('User canceled the confirmation'), + ); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'User canceled the confirmation', + ); + + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + it('propagates errors from parsePsbt', async () => { const error = new Error('parsePsbt'); jest.mocked(parsePsbt).mockImplementationOnce(() => { @@ -109,7 +146,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -131,6 +171,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -172,7 +213,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -194,6 +238,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -233,7 +278,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -256,6 +304,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -295,7 +344,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -324,6 +376,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + ...accountParam, recipients, feeRate: 3, }, @@ -376,6 +429,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.GetUtxo, params: { + ...accountParam, outpoint: 'mytxid:0', }, }, @@ -470,6 +524,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignMessage, params: { + ...accountParam, message: 'message', }, }, @@ -491,4 +546,130 @@ describe('KeyringRequestHandler', () => { }); }); }); + + describe('resolveAccountAddress', () => { + const mockAccount1 = mock({ + id: 'account-1', + address: 'test123', + scopes: [BtcScope.Regtest], + }); + const mockAccount2 = mock({ + id: 'account-2', + address: 'test456', + scopes: [BtcScope.Regtest, BtcScope.Testnet], + }); + const mockAccount3 = mock({ + id: 'account-3', + address: 'test789', + scopes: [BtcScope.Testnet], + }); + + it('resolves account address for SignPsbt', () => { + const request = { + method: BtcMethod.SignPsbt as const, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + options: { fill: false, broadcast: false }, + }, + }; + + const result = handler.resolveAccountAddress( + [mockAccount1, mockAccount2, mockAccount3], + BtcScope.Regtest, + request, + ); + + expect(result).toBe('bip122:regtest:test123'); + }); + + it('resolves account address for SendTransfer', () => { + const request = { + method: BtcMethod.SendTransfer as const, + params: { + account: { address: 'test456' }, + recipients: [{ address: 'recipient', amount: '1000' }], + }, + }; + + const result = handler.resolveAccountAddress( + [mockAccount1, mockAccount2, mockAccount3], + BtcScope.Regtest, + request, + ); + + expect(result).toBe('bip122:regtest:test456'); + }); + + it('resolves account address for SignMessage', () => { + const request = { + method: BtcMethod.SignMessage as const, + params: { + account: { address: 'test123' }, + message: 'hello', + }, + }; + + const result = handler.resolveAccountAddress( + [mockAccount1, mockAccount2, mockAccount3], + BtcScope.Regtest, + request, + ); + + expect(result).toBe('bip122:regtest:test123'); + }); + + it('throws error if no accounts with scope', () => { + const request = { + method: BtcMethod.SignPsbt as const, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + options: { fill: false, broadcast: false }, + }, + }; + + expect(() => + handler.resolveAccountAddress( + [mockAccount3], + BtcScope.Regtest, + request, + ), + ).toThrow('No accounts with this scope'); + }); + + it('throws error if account address not found', () => { + const request = { + method: BtcMethod.SignPsbt as const, + params: { + account: { address: 'notfound' }, + psbt: 'psbt', + options: { fill: false, broadcast: false }, + }, + }; + + expect(() => + handler.resolveAccountAddress( + [mockAccount1, mockAccount2, mockAccount3], + BtcScope.Regtest, + request, + ), + ).toThrow('Account not found'); + }); + + it('throws error for unsupported method', () => { + const request = { + method: 'unsupported' as BtcMethod, + params: {}, + }; + + expect(() => + handler.resolveAccountAddress( + [mockAccount1, mockAccount2, mockAccount3], + BtcScope.Regtest, + request as unknown as BtcWalletRequest, + ), + ).toThrow('Unsupported method'); + }); + }); }); diff --git a/packages/snap/src/handlers/KeyringRequestHandler.ts b/packages/snap/src/handlers/KeyringRequestHandler.ts index fb84f7a8..51991d82 100644 --- a/packages/snap/src/handlers/KeyringRequestHandler.ts +++ b/packages/snap/src/handlers/KeyringRequestHandler.ts @@ -1,15 +1,24 @@ -import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; -import type { Json } from '@metamask/snaps-sdk'; +import { + BtcMethod, + type KeyringAccount, + type KeyringRequest, + type KeyringResponse, +} from '@metamask/keyring-api'; +import type { CaipAccountId, CaipChainId, Json } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; import { array, assert, boolean, + literal, number, object, optional, string, + union, } from 'superstruct'; +import type { ConfirmationRepository } from '../entities'; import { AccountCapability, InexistentMethodError, @@ -19,7 +28,15 @@ import { mapToUtxo } from './mappings'; import { parsePsbt } from './parsers'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; +/** + * Wallet account struct for Bitcoin requests. + */ +const WalletAccountStruct = object({ + address: string(), +}); + export const SignPsbtRequest = object({ + account: WalletAccountStruct, psbt: string(), feeRate: optional(number()), options: object({ @@ -34,6 +51,7 @@ export type SignPsbtResponse = { }; export const ComputeFeeRequest = object({ + account: WalletAccountStruct, psbt: string(), feeRate: optional(number()), }); @@ -44,6 +62,7 @@ export type ComputeFeeResponse = { }; export const BroadcastPsbtRequest = object({ + account: WalletAccountStruct, psbt: string(), }); @@ -52,6 +71,7 @@ export type BroadcastPsbtResponse = { }; export const FillPsbtRequest = object({ + account: WalletAccountStruct, psbt: string(), feeRate: optional(number()), }); @@ -61,6 +81,7 @@ export type FillPsbtResponse = { }; export const SendTransferRequest = object({ + account: WalletAccountStruct, recipients: array( object({ address: string(), @@ -71,10 +92,12 @@ export const SendTransferRequest = object({ }); export const GetUtxoRequest = object({ + account: WalletAccountStruct, outpoint: string(), }); export const SignMessageRequest = object({ + account: WalletAccountStruct, message: string(), }); @@ -82,11 +105,71 @@ export type SignMessageResponse = { signature: string; }; +/** + * Validates that a JsonRpcRequest is a valid Bitcoin request. + * + * TODO: update btc-methods.md to include all the new methods + * + * @see https://github.com/MetaMask/accounts/blob/main/packages/keyring-api/docs/btc-methods.md + */ +export const SignPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.SignPsbt), + params: SignPsbtRequest, +}); + +export const FillPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.FillPsbt), + params: FillPsbtRequest, +}); + +export const ComputeFeeKeyringRequestStruct = object({ + method: literal(BtcMethod.ComputeFee), + params: ComputeFeeRequest, +}); + +export const BroadcastPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.BroadcastPsbt), + params: BroadcastPsbtRequest, +}); + +export const SendTransferKeyringRequestStruct = object({ + method: literal(BtcMethod.SendTransfer), + params: SendTransferRequest, +}); + +export const GetUtxoKeyringRequestStruct = object({ + method: literal(BtcMethod.GetUtxo), + params: GetUtxoRequest, +}); + +export const SignMessageKeyringRequestStruct = object({ + method: literal(BtcMethod.SignMessage), + params: SignMessageRequest, +}); + +export const BtcWalletRequestStruct = union([ + SignPsbtKeyringRequestStruct, + FillPsbtKeyringRequestStruct, + ComputeFeeKeyringRequestStruct, + BroadcastPsbtKeyringRequestStruct, + SendTransferKeyringRequestStruct, + GetUtxoKeyringRequestStruct, + SignMessageKeyringRequestStruct, +]); + +export type BtcWalletRequest = Infer; + export class KeyringRequestHandler { readonly #accountsUseCases: AccountUseCases; - constructor(accounts: AccountUseCases) { + readonly #confirmationRepository: ConfirmationRepository; + + constructor( + accounts: AccountUseCases, + confirmationRepository: ConfirmationRepository, + ) { this.#accountsUseCases = accounts; + this.#confirmationRepository = confirmationRepository; } async route(request: KeyringRequest): Promise { @@ -153,15 +236,25 @@ export class KeyringRequestHandler { options: { fill: boolean; broadcast: boolean }, feeRate?: number, ): Promise { - const { psbt, txid } = await this.#accountsUseCases.signPsbt( + const psbt = parsePsbt(psbtBase64); + const account = await this.#accountsUseCases.get(id); + + await this.#confirmationRepository.insertSignPsbt( + account, + psbt, + origin, + options, + ); + + const { psbt: signedPsbt, txid } = await this.#accountsUseCases.signPsbt( id, - parsePsbt(psbtBase64), + psbt, origin, options, feeRate, ); return this.#toKeyringResponse({ - psbt: psbt.toString(), + psbt: signedPsbt.toString(), txid: txid?.toString() ?? null, } as SignPsbtResponse); } @@ -270,4 +363,61 @@ export class KeyringRequestHandler { result, }; } + + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param keyringAccounts - The accounts available in the keyring. + * @param scope - Request's scope (CAIP-2). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address that must + * be used to process this signing request, or null if none candidates + * could be found. + * @throws If the request is invalid. + */ + resolveAccountAddress( + keyringAccounts: KeyringAccount[], + scope: CaipChainId, + request: Infer, + ): CaipAccountId { + const accountsWithThisScope = keyringAccounts.filter((account) => + account.scopes.includes(scope), + ); + + if (accountsWithThisScope.length === 0) { + throw new Error('No accounts with this scope'); + } + + let addressToValidate: string; + + switch (request.method) { + case BtcMethod.BroadcastPsbt: + case BtcMethod.FillPsbt: + case BtcMethod.ComputeFee: + case BtcMethod.GetUtxo: + case BtcMethod.SendTransfer: + case BtcMethod.SignMessage: + case BtcMethod.SignPsbt: { + const { account } = request.params; + addressToValidate = account.address; + break; + } + default: { + throw new Error('Unsupported method'); + } + } + + const foundAccount = accountsWithThisScope.find( + (account) => account.address === addressToValidate, + ); + + if (!foundAccount) { + throw new Error('Account not found'); + } + + return `${scope}:${addressToValidate}`; + } } diff --git a/packages/snap/src/handlers/caip.ts b/packages/snap/src/handlers/caip.ts index 182f0654..fc8ea620 100644 --- a/packages/snap/src/handlers/caip.ts +++ b/packages/snap/src/handlers/caip.ts @@ -1,5 +1,6 @@ import type { AddressType, Network } from '@metamask/bitcoindevkit'; import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { enums } from 'superstruct'; const reverseMapping = < From extends string | number | symbol, @@ -37,6 +38,8 @@ export enum Caip19Asset { Regtest = 'bip122:regtest/slip44:0', } +export const NetworkStruct = enums(Object.values(BtcScope)); + export const networkToCaip19: Record = { bitcoin: Caip19Asset.Bitcoin, testnet: Caip19Asset.Testnet, diff --git a/packages/snap/src/index.ts b/packages/snap/src/index.ts index 5759e0c7..c6e612fe 100644 --- a/packages/snap/src/index.ts +++ b/packages/snap/src/index.ts @@ -51,6 +51,8 @@ const sendFlowRepository = new JSXSendFlowRepository(snapClient, translator); const confirmationRepository = new JSXConfirmationRepository( snapClient, translator, + chainClient, + assetRatesClient, ); // Business layer @@ -83,7 +85,10 @@ const assetsUseCases = new AssetsUseCases( const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); // Application layer -const keyringRequestHandler = new KeyringRequestHandler(accountsUseCases); +const keyringRequestHandler = new KeyringRequestHandler( + accountsUseCases, + confirmationRepository, +); const keyringHandler = new KeyringHandler( keyringRequestHandler, accountsUseCases, diff --git a/packages/snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx b/packages/snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx new file mode 100644 index 00000000..56f0937c --- /dev/null +++ b/packages/snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx @@ -0,0 +1,188 @@ +import { + Address, + Box, + Button, + Container, + Copyable, + Footer, + Section, + Text as SnapText, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, SignPsbtConfirmationContext } from '../../../entities'; +import { ConfirmationEvent } from '../../../entities'; +import { AssetIconInline, HeadingWithReturn } from '../components'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayNetwork, + translate, +} from '../format'; + +type SignPsbtConfirmationViewProps = { + context: SignPsbtConfirmationContext; + messages: Messages; +}; + +export const SignPsbtConfirmationView: SnapComponent< + SignPsbtConfirmationViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { + account, + network, + origin, + psbt, + options, + fee, + currency, + exchangeRate, + outputs, + inputCount, + } = context; + + return ( + + + + + {outputs.length > 0 ? ( +
+ + + {t('confirmation.signPsbt.outputs')} + + + {outputs.map((output, index) => { + let label: string; + if (output.isOpReturn) { + label = t('confirmation.signPsbt.output.opReturn'); + } else if (output.isMine) { + label = t('confirmation.signPsbt.output.change'); + } else if (output.address) { + label = `${t('toAddress')} #${index + 1}`; + } else { + label = t('confirmation.signPsbt.output.unknown'); + } + + return ( + + + {label} + + + {displayExchangeAmount( + BigInt(output.amount), + exchangeRate, + )} + + + {displayAmount(BigInt(output.amount), currency)} + + + + {output.address ? ( + +
+ + ) : null} + + ); + })} +
+ ) : null} + +
+ + + {t('confirmation.signPsbt.options')} + + + + + {t('confirmation.signPsbt.options.fill')} + + {options.fill ? t('yes') : t('no')} + + + + {t('confirmation.signPsbt.options.broadcast')} + + {options.broadcast ? t('yes') : t('no')} + + {inputCount > 0 ? ( + + + {t('confirmation.signPsbt.inputs')} + + {inputCount.toString()} + + ) : null} + {fee === undefined ? null : ( + + {t('networkFee')} + + + {displayExchangeAmount(BigInt(fee), exchangeRate)} + + {displayAmount(BigInt(fee), currency)} + + + )} +
+ +
+ + + {t('confirmation.requestOrigin')} + + {origin ?? 'MetaMask'} + + {null} + + + {t('from')} + +
+ + {null} + + + {t('network')} + + + + {displayNetwork(network)} + + +
+ +
+ + + {t('confirmation.signPsbt.rawPsbt')} + + + +
+
+
+ + +
+
+ ); +}; diff --git a/packages/snap/src/infra/jsx/confirmations/index.ts b/packages/snap/src/infra/jsx/confirmations/index.ts index bcd5e740..9d8461da 100644 --- a/packages/snap/src/infra/jsx/confirmations/index.ts +++ b/packages/snap/src/infra/jsx/confirmations/index.ts @@ -1 +1,2 @@ export * from './SignMessageConfirmationView'; +export * from './SignPsbtConfirmationView'; diff --git a/packages/snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/packages/snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index cc36b6df..b283b771 100644 --- a/packages/snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/packages/snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -35,8 +35,15 @@ export const UnifiedSendFormView: SnapComponent = ({ messages, }) => { const t = translate(messages); - const { amount, exchangeRate, network, from, recipient, explorerUrl } = - context; + const { + amount, + exchangeRate, + network, + from, + recipient, + explorerUrl, + origin, + } = context; const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); @@ -83,7 +90,7 @@ export const UnifiedSendFormView: SnapComponent = ({ {t('confirmation.requestOrigin')} - MetaMask + {origin ?? 'MetaMask'} {null} diff --git a/packages/snap/src/store/JSXConfirmationRepository.test.tsx b/packages/snap/src/store/JSXConfirmationRepository.test.tsx index c1e084b1..3390a699 100644 --- a/packages/snap/src/store/JSXConfirmationRepository.test.tsx +++ b/packages/snap/src/store/JSXConfirmationRepository.test.tsx @@ -1,21 +1,41 @@ -import type { Address } from '@metamask/bitcoindevkit'; +import type { Address, Psbt } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { SnapClient, Translator, BitcoinAccount } from '../entities'; +import type { + AssetRatesClient, + SnapClient, + Translator, + BitcoinAccount, + BlockchainClient, + SpotPrice, +} from '../entities'; +import { networkToCurrencyUnit } from '../entities'; import { JSXConfirmationRepository } from './JSXConfirmationRepository'; import { SignMessageConfirmationView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; jest.mock('../infra/jsx', () => ({ SignMessageConfirmationView: jest.fn(), })); +jest.mock('../infra/jsx/unified-send-flow', () => ({ + UnifiedSendFormView: jest.fn(), +})); + describe('JSXConfirmationRepository', () => { const mockMessages = { foo: { message: 'bar' } }; const mockSnapClient = mock(); const mockTranslator = mock(); + const mockChainClient = mock(); + const mockRatesClient = mock(); - const repo = new JSXConfirmationRepository(mockSnapClient, mockTranslator); + const repo = new JSXConfirmationRepository( + mockSnapClient, + mockTranslator, + mockChainClient, + mockRatesClient, + ); describe('insertSignMessage', () => { const mockAccount = mock({ @@ -38,9 +58,9 @@ describe('JSXConfirmationRepository', () => { mockSnapClient.createInterface.mockResolvedValue('interface-id'); mockSnapClient.displayConfirmation.mockResolvedValue(true); mockTranslator.load.mockResolvedValue(mockMessages); - mockSnapClient.getPreferences.mockResolvedValue({ - locale: 'en', - } as GetPreferencesResult); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en' }), + ); }); it('creates and displays a sign message interface', async () => { @@ -67,4 +87,117 @@ describe('JSXConfirmationRepository', () => { ).rejects.toThrow('User canceled the confirmation'); }); }); + + describe('insertSendTransfer', () => { + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'fromAddress' }), + }); + const mockPsbt = mock({ + toString: () => 'serialized-psbt', + }); + const recipient = { address: 'toAddress', amount: '50000' }; + const origin = 'dapp-origin'; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('send-interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en', currency: 'usd' }), + ); + mockChainClient.getExplorerUrl.mockReturnValue('https://mempool.space'); + mockRatesClient.spotPrices.mockResolvedValue( + mock({ price: 50000 }), + ); + }); + + it('creates and displays a send transfer interface', async () => { + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + const expectedContext = { + from: 'fromAddress', + explorerUrl: 'https://mempool.space', + network: mockAccount.network, + currency: networkToCurrencyUnit[mockAccount.network], + exchangeRate: expect.objectContaining({ + conversionRate: 50000, + currency: 'USD', + }), + recipient: recipient.address, + amount: recipient.amount, + locale: 'en', + psbt: 'serialized-psbt', + origin, + }; + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockChainClient.getExplorerUrl).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'send-interface-id', + ); + }); + + it('throws UserActionError if the user cancels', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('sets exchangeRate to undefined for non-mainnet networks', async () => { + const testnetAccount = mock({ + id: 'account-id', + network: 'testnet', + publicAddress: mock
({ toString: () => 'fromAddress' }), + }); + + await repo.insertSendTransfer( + testnetAccount, + mockPsbt, + recipient, + origin, + ); + + expect(mockRatesClient.spotPrices).not.toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when spot price is null', async () => { + // @ts-expect-error - testing runtime guard against API returning null + mockRatesClient.spotPrices.mockResolvedValue({ price: null }); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when rates client throws', async () => { + mockRatesClient.spotPrices.mockRejectedValue(new Error('API error')); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + }); }); diff --git a/packages/snap/src/store/JSXConfirmationRepository.tsx b/packages/snap/src/store/JSXConfirmationRepository.tsx index 73ee9874..6887745d 100644 --- a/packages/snap/src/store/JSXConfirmationRepository.tsx +++ b/packages/snap/src/store/JSXConfirmationRepository.tsx @@ -1,21 +1,46 @@ +import type { Psbt } from '@metamask/bitcoindevkit'; +import { Address as BdkAddress } from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import type { CurrencyRate } from '@metamask/snaps-sdk'; + import type { + AssetRatesClient, BitcoinAccount, + BlockchainClient, ConfirmationRepository, + ConfirmSendFormContext, SignMessageConfirmationContext, + SignPsbtConfirmationContext, + SignPsbtOutput, SnapClient, Translator, } from '../entities'; -import { UserActionError } from '../entities'; -import { SignMessageConfirmationView } from '../infra/jsx'; +import { networkToCurrencyUnit, UserActionError } from '../entities'; +import { + SignMessageConfirmationView, + SignPsbtConfirmationView, +} from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; export class JSXConfirmationRepository implements ConfirmationRepository { readonly #snapClient: SnapClient; readonly #translator: Translator; - constructor(snapClient: SnapClient, translator: Translator) { + readonly #chainClient: BlockchainClient; + + readonly #ratesClient: AssetRatesClient; + + constructor( + snapClient: SnapClient, + translator: Translator, + chainClient: BlockchainClient, + ratesClient: AssetRatesClient, + ) { this.#snapClient = snapClient; this.#translator = translator; + this.#chainClient = chainClient; + this.#ratesClient = ratesClient; } async insertSignMessage( @@ -48,4 +73,138 @@ export class JSXConfirmationRepository implements ConfirmationRepository { throw new UserActionError('User canceled the confirmation'); } } + + async insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + const context: ConfirmSendFormContext = { + from: account.publicAddress.toString(), + explorerUrl: this.#chainClient.getExplorerUrl(account.network), + network: account.network, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + recipient: recipient.address, + amount: recipient.amount, + locale, + psbt: psbt.toString(), + origin, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + let fee: string | undefined; + try { + const feeAmount = psbt.fee_amount(); + if (feeAmount) { + fee = feeAmount.to_sat().toString(); + } + } catch { + fee = undefined; + } + + const outputs: SignPsbtOutput[] = []; + for (const txout of psbt.unsigned_tx.output) { + const isOpReturn = txout.script_pubkey.is_op_return(); + const isMine = account.isMine(txout.script_pubkey); + + let address: string | undefined; + if (!isOpReturn) { + try { + address = BdkAddress.from_script( + txout.script_pubkey, + account.network, + ).toString(); + } catch { + address = undefined; + } + } + + outputs.push({ + address, + amount: txout.value.to_sat().toString(), + isMine, + isOpReturn, + }); + } + + const context: SignPsbtConfirmationContext = { + psbt: psbt.toString(), + origin, + account: { + id: account.id, + address: account.publicAddress.toString(), + }, + network: account.network, + options, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + fee, + outputs, + inputCount: psbt.unsigned_tx.input.length, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async #getExchangeRate( + network: string, + currency: string, + ): Promise { + if (network !== 'bitcoin') { + return undefined; + } + + try { + const spotPrice = await this.#ratesClient.spotPrices(currency); + + if (spotPrice.price === undefined || spotPrice.price === null) { + return undefined; + } + + return { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } catch { + // Exchange rates are optional display information - don't fail if unavailable + return undefined; + } + } } diff --git a/packages/snap/src/use-cases/AccountUseCases.test.ts b/packages/snap/src/use-cases/AccountUseCases.test.ts index f5c8ae9e..71f11e39 100644 --- a/packages/snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/snap/src/use-cases/AccountUseCases.test.ts @@ -1507,10 +1507,6 @@ describe('AccountUseCases', () => { address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', amount: '1000', }, - { - address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - amount: '2000', - }, ]; const mockTxid = mock(); const mockOutput = mock({ @@ -1574,6 +1570,23 @@ describe('AccountUseCases', () => { mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); }); + it('throws error if there are multiple recipients', async () => { + const multipleRecipients = [ + { address: 'addr1', amount: '1000' }, + { address: 'addr2', amount: '2000' }, + ]; + + await expect( + useCases.sendTransfer('account-id', multipleRecipients, 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); + }); + + it('throws error if there are no recipients', async () => { + await expect( + useCases.sendTransfer('account-id', [], 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); + }); + it('throws error if account is not found', async () => { mockRepository.getWithSigner.mockResolvedValue(null); @@ -1585,7 +1598,6 @@ describe('AccountUseCases', () => { it('sends funds', async () => { mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); const txid = await useCases.sendTransfer( 'account-id', @@ -1598,10 +1610,6 @@ describe('AccountUseCases', () => { '1000', 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', ); - expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( - '2000', - 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - ); expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( mockAccount.network, ); diff --git a/packages/snap/src/use-cases/AccountUseCases.ts b/packages/snap/src/use-cases/AccountUseCases.ts index 11d75b91..9f0a9337 100644 --- a/packages/snap/src/use-cases/AccountUseCases.ts +++ b/packages/snap/src/use-cases/AccountUseCases.ts @@ -461,21 +461,37 @@ export class AccountUseCases { recipients, ); + if (!recipients[0] || recipients.length > 1) { + throw new ValidationError('There should be exactly one recipient', { + recipients, + }); + } + const recipient = recipients[0]; + const account = await this.#repository.getWithSigner(id); if (!account) { throw new NotFoundError('Account not found', { id }); } this.#checkCapability(account, AccountCapability.SendTransfer); - // Create a template PSBT with the recipients as outputs - let builder = account.buildTx(); - for (const { address, amount } of recipients) { - builder = builder.addRecipient(amount, address); - } - const templatePsbt = builder.finish(); + const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); + const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); + + // Build PSBT with the recipient as output + const psbt = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .addRecipient(recipient.amount, recipient.address) + .finish(); + + await this.#confirmationRepository.insertSendTransfer( + account, + psbt, + recipient, + origin, + ); - // Complete the PSBT with the necessary inputs, fee rate, etc. - const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); const signedPsbt = account.sign(psbt); const tx = account.extractTransaction(signedPsbt); const txid = await this.#broadcast(account, tx, origin);