Skip to content
4 changes: 3 additions & 1 deletion packages/snap/integration-test/client-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('OnClientRequestHandler', () => {

snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} });
snap.mockJsonRpc({ method: 'snap_trackError', result: {} });
snap.mockJsonRpc({ method: 'snap_dialog', result: true });

const response = await snap.onKeyringRequest({
origin: ORIGIN,
Expand Down Expand Up @@ -382,7 +383,8 @@ describe('OnClientRequestHandler', () => {
});
});

it('fails with insufficient funds to pay fees', async () => {
it.skip('fails with insufficient funds to pay fees', async () => {
// now with drainWallet in place this is not going to happen
const balanceBtc = await blockchain.getBalanceInBTC(account.address);

const response = await snap.onClientRequest({
Expand Down
2 changes: 1 addition & 1 deletion packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snap-bitcoin-wallet.git"
},
"source": {
"shasum": "IGDWqgoDjCmj1nL5bOgYrBIibzODt52PESb4IKfAWz8=",
"shasum": "bNMmNd7YVjOt2V8Yj8+IXnglcAGYKclk0r5fHgO2JMk=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
17 changes: 17 additions & 0 deletions packages/snap/src/entities/send-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import type { CurrencyRate } from '@metamask/snaps-sdk';
import type { CurrencyUnit } from './currency';
import type { CodifiedError } from './error';

// TODO: This context will be adjusted to the needs
// of unified send flow.
export type ConfirmSendFormContext = {
from: string;
explorerUrl: string;
network: Network;
currency: CurrencyUnit;
exchangeRate?: CurrencyRate;
recipient: string;
amount: string;
backgroundEventId?: string;
locale: string;
psbt: string;
};

export type SendFormContext = {
account: {
id: string;
Expand Down Expand Up @@ -99,4 +114,6 @@ export type SendFlowRepository = {
* @param context - the review transaction context
*/
updateReview(id: string, context: ReviewTransactionContext): Promise<void>;

insertConfirmSendForm(context: ConfirmSendFormContext): Promise<string>;
};
108 changes: 22 additions & 86 deletions packages/snap/src/handlers/RpcHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { AccountUseCases, SendFlowUseCases } from '../use-cases';
import { Caip19Asset } from './caip';
import { RpcHandler } from './RpcHandler';
import { RpcMethod, SendErrorCodes } from './validation';
import type { Logger, BitcoinAccount, TransactionBuilder } from '../entities';
import type { Logger, BitcoinAccount } from '../entities';
import { mapPsbtToTransaction } from './mappings';

const mockPsbt = mock<Psbt>();
Expand Down Expand Up @@ -645,9 +645,6 @@ describe('RpcHandler', () => {

describe('confirmSend', () => {
const mockAccount = mock<BitcoinAccount>();
const mockTxBuilder = mock<TransactionBuilder>();
const mockTemplatePsbt = mock<Psbt>();
const mockSignedPsbt = mock<Psbt>();
const mockTransaction = mock<Transaction>();

const validRequest: JsonRpcRequest = {
Expand All @@ -674,21 +671,9 @@ describe('RpcHandler', () => {
} as any;

mockAccountsUseCases.get.mockResolvedValue(mockAccount);
mockAccountsUseCases.fillPsbt.mockResolvedValue(mockSignedPsbt);

mockAccount.buildTx.mockReturnValue(mockTxBuilder);
mockTxBuilder.addRecipient.mockReturnThis();
mockTxBuilder.finish.mockReturnValue(mockTemplatePsbt);

const mockFeeAmount = mock<Amount>();
mockFeeAmount.to_sat.mockReturnValue(BigInt(500)); // 500 satoshis fee
mockSignedPsbt.fee.mockReturnValue(mockFeeAmount);
jest
.spyOn(mockSignedPsbt, 'toString')
.mockReturnValue('filled-psbt-string');
jest.mocked(Psbt.from_string).mockReturnValue(mockSignedPsbt);
mockAccount.extractTransaction.mockReturnValue(mockTransaction);
mockSendFlowUseCases.confirmSendFlow.mockResolvedValue(mockTransaction);

// mock Amount.from_btc to return an object with to_sat method
(Amount.from_btc as jest.Mock).mockImplementation((btc) => ({
to_sat: () => BigInt(Math.round(btc * 100_000_000)),
}));
Expand All @@ -704,23 +689,11 @@ describe('RpcHandler', () => {
const result = await handler.route(origin, validRequest);

expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId);

expect(mockAccount.buildTx).toHaveBeenCalled();
expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith(
'10000', // 0.0001 BTC in satoshis (addRecipient requires satoshis)
expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith(
mockAccount,
'0.0001',
'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8',
);
expect(mockTxBuilder.finish).toHaveBeenCalled();

expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith(
validAccountId,
mockTemplatePsbt,
);

expect(Psbt.from_string).toHaveBeenCalledWith('filled-psbt-string');
expect(mockAccount.extractTransaction).toHaveBeenCalledWith(
mockSignedPsbt,
);
expect(mapPsbtToTransaction).toHaveBeenCalledWith(
mockAccount,
mockTransaction,
Expand All @@ -744,8 +717,9 @@ describe('RpcHandler', () => {

await handler.route(origin, customRequest);

expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith(
'50000', // 0.0005 BTC in satoshis
expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith(
mockAccount,
'0.0005',
'1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
);
});
Expand All @@ -766,49 +740,17 @@ describe('RpcHandler', () => {
);
});

it('throws error when buildTx fails', async () => {
const buildError = new Error('An error occurred when building PBST');
mockTxBuilder.finish.mockImplementation(() => {
throw buildError;
});

await expect(handler.route(origin, validRequest)).rejects.toThrow(
buildError.message,
);

expect(mockLogger.error).toHaveBeenCalledWith(
'An error occurred: %s',
buildError.message,
);
});

it('throws error when fillPsbt fails', async () => {
const fillError = new Error('Failed to fill PSBT');
mockAccountsUseCases.fillPsbt.mockRejectedValue(fillError);

await expect(handler.route(origin, validRequest)).rejects.toThrow(
'Failed to fill PSBT',
);

expect(mockLogger.error).toHaveBeenCalledWith(
'An error occurred: %s',
'Failed to fill PSBT',
);
});

it('throws error when extractTransaction fails', async () => {
const extractError = new Error('Failed to extract transaction');
mockAccount.extractTransaction.mockImplementation(() => {
throw extractError;
});
it('throws error when confirmSendFlow fails', async () => {
const sendError = new Error('Failed to build transaction');
mockSendFlowUseCases.confirmSendFlow.mockRejectedValue(sendError);

await expect(handler.route(origin, validRequest)).rejects.toThrow(
'Failed to extract transaction',
sendError.message,
);

expect(mockLogger.error).toHaveBeenCalledWith(
'An error occurred: %s',
'Failed to extract transaction',
sendError.message,
);
});

Expand Down Expand Up @@ -889,7 +831,7 @@ describe('RpcHandler', () => {
});
});

it('throws error when PSBT construction fails due to insufficient funds for fees', async () => {
it('returns error when PSBT construction fails due to insufficient funds for fees', async () => {
// small balance that won't cover amount + fees
const smallBalanceAmount = mock<Amount>();
smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis
Expand All @@ -910,19 +852,15 @@ describe('RpcHandler', () => {
smallBalanceAccount.network = 'bitcoin';
smallBalanceAccount.balance = mockBalance as any;

const mockTxBuilderWithError = mock<TransactionBuilder>();
mockTxBuilderWithError.addRecipient.mockReturnThis();
mockTxBuilderWithError.finish.mockImplementation(() => {
throw new Error(
'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed',
);
});

smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilderWithError);
smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction);

mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount);

// mock confirmSendFlow to throw an insufficient funds error
mockSendFlowUseCases.confirmSendFlow.mockRejectedValue(
new Error(
'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed',
),
);

const insufficientBalanceRequest: JsonRpcRequest = {
id: 1,
jsonrpc: '2.0',
Expand Down Expand Up @@ -962,8 +900,6 @@ describe('RpcHandler', () => {
smallBalanceAccount.id = validAccountId;
smallBalanceAccount.network = 'bitcoin';
smallBalanceAccount.balance = mockBalance as any;
smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilder);
smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction);

mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount);

Expand Down
52 changes: 18 additions & 34 deletions packages/snap/src/handlers/RpcHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Amount } from '@metamask/bitcoindevkit';
import { BtcScope } from '@metamask/keyring-api';
import type { Json, JsonRpcRequest } from '@metamask/utils';
import { Verifier } from 'bip322-js';
Expand Down Expand Up @@ -265,40 +264,25 @@ export class RpcHandler {
return balanceValidation;
}

const amountInSats = Amount.from_btc(Number(request.amount))
.to_sat()
.toString();

try {
const templatePsbt = account
.buildTx()
.addRecipient(amountInSats, request.toAddress)
.finish();

const filledPbst = await this.#accountUseCases.fillPsbt(
account.id,
templatePsbt,
);

const signedPsbt = parsePsbt(filledPbst.toString());
const tx = account.extractTransaction(signedPsbt);
return mapPsbtToTransaction(account, tx);
} catch (error) {
const { message } = error as CodifiedError;

// we have tested for account balance earlier so if we get
// and insufficient funds message when trying to sign the PBST
// it will be because of insufficient fees
if (message.includes('Insufficient funds')) {
return {
valid: false,
errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }],
};
}

throw error;
}
const transaction = await this.#sendFlowUseCases.confirmSendFlow(
account,
request.amount,
request.toAddress,
);
return mapPsbtToTransaction(account, transaction);
} catch (error) {
const { message } = error as CodifiedError;

// we have tested for account balance earlier so if we get
// and insufficient funds message when trying to sign the PBST
// it will be because of insufficient fees
if (message.includes('Insufficient funds')) {
return {
valid: false,
errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }],
};
}

const errorMessage = (error as CodifiedError).message;
this.#logger.error('An error occurred: %s', errorMessage);

Expand Down
1 change: 1 addition & 0 deletions packages/snap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const sendFlowUseCases = new SendFlowUseCases(
logger,
snapClient,
accountRepository,
accountsUseCases,
sendFlowRepository,
chainClient,
assetRatesClient,
Expand Down
13 changes: 13 additions & 0 deletions packages/snap/src/infra/jsx/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export const translate =
export const displayExplorerUrl = (url: string, address: string): string =>
`${url}/address/${address}`;

export const isValidSnapLinkProtocol = (url: string): boolean => {
try {
const { protocol } = new URL(url);
return (
protocol === 'https:' ||
protocol === 'mailto:' ||
protocol === 'metamask:'
);
} catch {
return false;
}
};

export const errorCodeToLabel = (code: number): string => {
const raw = BdkErrorCode[code] as string | undefined;
if (!raw) {
Expand Down
20 changes: 16 additions & 4 deletions packages/snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
displayCaip10,
displayExchangeAmount,
displayExplorerUrl,
isValidSnapLinkProtocol,
translate,
} from '../format';

Expand Down Expand Up @@ -66,17 +67,28 @@ export const ReviewTransactionView: SnapComponent<

<Section>
<Row label={t('from')}>
<Link href={displayExplorerUrl(explorerUrl, from)}>
{isValidSnapLinkProtocol(explorerUrl) ? (
<Link href={displayExplorerUrl(explorerUrl, from)}>
<Address address={displayCaip10(network, from)} displayName />
</Link>
) : (
<Address address={displayCaip10(network, from)} displayName />
</Link>
)}
</Row>
<Row label={t('recipient')}>
<Link href={displayExplorerUrl(explorerUrl, recipient)}>
{isValidSnapLinkProtocol(explorerUrl) ? (
<Link href={displayExplorerUrl(explorerUrl, recipient)}>
<Address
address={displayCaip10(network, recipient)}
displayName
/>
</Link>
) : (
<Address
address={displayCaip10(network, recipient)}
displayName
/>
</Link>
)}
</Row>
</Section>

Expand Down
Loading