Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/bitcoin-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `signProofOfOwnershipBatch` for signing multiple proof-of-ownership messages in one request. ([#266](https://github.com/MetaMask/internal-snaps/pull/266))
- Emit `Transaction Added`, `Transaction Approved`, and `Transaction Rejected` tracking events from Bitcoin transaction confirmations ([#328](https://github.com/MetaMask/internal-snaps/pull/328), [#329](https://github.com/MetaMask/internal-snaps/pull/329))

### Fixed

- Populate the `from` address of receive transactions by resolving the addresses that funded them through the chain indexer. Bitcoin inputs only reference a previous outpoint, so the sender was previously left empty. ([#372](https://github.com/MetaMask/internal-snaps/pull/372))

## [3.0.0]

### Added
Expand Down
15 changes: 14 additions & 1 deletion packages/bitcoin-wallet-snap/integration-test/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ export const FUNDING_TX = {
{ status: 'confirmed', timestamp: expect.any(Number) },
],
fees: [],
from: [],
// The receive counterparty is resolved from the chain indexer, so the sender
// address depends on the funding wallet used by the regtest fixture. A tx
// can be funded by more than one address, so match a subset.
from: expect.arrayContaining([
{
address: expect.any(String),
asset: {
amount: '0',
fungible: true,
type: Caip19Asset.Regtest,
unit: CurrencyUnit.Regtest,
},
},
]),
id: expect.any(String),
status: 'confirmed',
timestamp: expect.any(Number),
Expand Down
8 changes: 4 additions & 4 deletions packages/bitcoin-wallet-snap/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ module.exports = {
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
branches: 74.89,
functions: 62.34,
lines: 82.62,
statements: 82.4,
branches: 75.74,
functions: 64.28,
lines: 83.25,
statements: 83.03,
},
},
};
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-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/internal-snaps.git"
},
"source": {
"shasum": "ym8AM1UALP/J8Al0hkltshJPyaZppBN8f0WpLj0JIJc=",
"shasum": "TVsL0SsAKXSyycYFuS00hOQzsg91kkRB8PpFDopVrOM=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
14 changes: 14 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,18 @@ export type BlockchainClient = {
* @returns the base url of the explorer
*/
getExplorerUrl(network: Network): string;

/**
* Resolve the distinct addresses that funded a transaction (the addresses of
* the outputs its inputs spend).
*
* Bitcoin inputs only reference the previous outpoint, so this requires the
* chain indexer and cannot be derived from the transaction alone. Used to
* populate the counterparty of receive transactions.
*
* @param network - Network the transaction belongs to.
* @param txid - Transaction id.
* @returns The funding addresses, deduped, in input order.
*/
getTransactionSenders(network: Network, txid: string): Promise<string[]>;
};
6 changes: 6 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export type SyncResult = {
account: BitcoinAccount;
// Transactions that changed and should be notified.
transactionsToNotify: WalletTx[];
// Funding addresses per txid, resolved by the chain indexer. Only populated
// for receives; used to display the counterparty.
transactionSenders?: Map<string, string[]>;
};

export const TrackingSnapEvent = {
Expand Down Expand Up @@ -140,10 +143,13 @@ export type SnapClient = {
*
* @param account - The Bitcoin account.
* @param txs - The transactions included in the event.
* @param sendersByTxid - Optional funding addresses per txid, used to
* populate the counterparty of receive transactions.
*/
emitAccountTransactionsUpdatedEvent(
account: BitcoinAccount,
txs: WalletTx[],
sendersByTxid?: Map<string, string[]>,
): Promise<void>;

/**
Expand Down
19 changes: 19 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ describe('CronHandler', () => {
).toHaveBeenCalledTimes(1);
});

it('forwards resolved senders when emitting transaction events', async () => {
const mockTx = mock<WalletTx>();
const senders = new Map([['txid-1', ['bc1qsender']]]);
const mockResult1: SyncResult = {
account: mockAccount1,
transactionsToNotify: [mockTx],
transactionSenders: senders,
};
(getSelectedAccounts as jest.Mock).mockResolvedValue(['account-1']);
mockAccountUseCases.list.mockResolvedValue([mockAccount1]);
mockAccountUseCases.synchronize.mockResolvedValueOnce(mockResult1);

await handler.route(request);

expect(
mockSnapClient.emitAccountTransactionsUpdatedEvent,
).toHaveBeenCalledWith(mockAccount1, [mockTx], senders);
});

it('propagates errors from list', async () => {
const error = new Error();
(getSelectedAccounts as jest.Mock).mockResolvedValue(['account-1']);
Expand Down
7 changes: 6 additions & 1 deletion packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,16 @@ export class CronHandler {
);

// Emit transaction events per account
for (const { account, transactionsToNotify } of results) {
for (const {
account,
transactionsToNotify,
transactionSenders,
} of results) {
if (transactionsToNotify.length > 0) {
await this.#snapClient.emitAccountTransactionsUpdatedEvent(
account,
transactionsToNotify,
...(transactionSenders ? [transactionSenders] : []),
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,7 @@ describe('KeyringHandler', () => {
mockAccounts.get.mockResolvedValue(mockAccount);
mockAccount.sentAndReceived.mockReturnValue([mockAmount, mockAmount]);
mockAccount.listTransactions.mockReturnValue([mockWalletTx]);
mockAccounts.resolveTransactionSenders.mockResolvedValue(new Map());
(Address.from_script as jest.Mock).mockReturnValue(mockAddress);
});

Expand Down Expand Up @@ -927,6 +928,37 @@ describe('KeyringHandler', () => {
]);
});

it('populates the receive counterparty from resolved senders', async () => {
const id = 'some-id';

mockAccount.sentAndReceived.mockReturnValueOnce([
{ ...mockAmount, to_btc: (): number => 0 },
mockAmount,
]);
mockAccount.isMine.mockReturnValueOnce(true);
mockAccounts.resolveTransactionSenders.mockResolvedValue(
new Map([['txid', ['bc1qsender']]]),
);

const result = await handler.getAccountTransactions(id, pagination);

expect(mockAccounts.resolveTransactionSenders).toHaveBeenCalledWith(
mockAccount,
[mockWalletTx],
);
expect(result.data[0]?.from).toStrictEqual([
{
address: 'bc1qsender',
asset: {
amount: '0',
fungible: true,
type: Caip19Asset.Bitcoin,
unit: CurrencyUnit.Bitcoin,
},
},
]);
});

it('respects limit and sets next to last txid', async () => {
const id = 'some-id';
const mockTransactions = Array.from({ length: 12 }, (_, index) => ({
Expand Down
13 changes: 12 additions & 1 deletion packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,19 @@ export class KeyringHandler implements KeyringSnapRpc {
? (paginatedTxs[paginatedTxs.length - 1]?.txid.toString() ?? null)
: null;

// Resolve the counterparty for receives from external senders. Only done
// for the returned page so the cost stays bounded, and best-effort so a
// transient indexer failure cannot break transaction listing.
const sendersByTxid =
await this.#accountsUseCases.resolveTransactionSenders(
account,
paginatedTxs,
);

return {
data: paginatedTxs.map((tx) => mapToTransaction(account, tx)),
data: paginatedTxs.map((tx) =>
mapToTransaction(account, tx, sendersByTxid?.get(tx.txid.toString())),
),
next: nextCursor,
};
}
Expand Down
86 changes: 86 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ describe('mapToTransaction', () => {
return mock<Transaction>({
compute_txid: () => mockTxid,
output: outputs,
input: [],
});
}

Expand Down Expand Up @@ -531,4 +532,89 @@ describe('mapToTransaction', () => {
'bc1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz',
);
});

it('populates from for a receive using the indexer-provided senders', () => {
const account = createMockAccount(0); // received: sent amount is 0
const output = createMockOutput(5000);
const transaction = createMockTransaction('receive111', [output]);

jest.spyOn(account, 'isMine').mockReturnValue(true);
jest.mocked(Address.from_script).mockImplementationOnce(
() =>
({
toString: () => 'bc1qreceiveoutput',
}) as unknown as Address,
);

const result = mapToTransaction(
account,
{
tx: transaction,
txid: transaction.compute_txid(),
chain_position: { anchor: undefined, last_seen: undefined },
} as unknown as WalletTx,
['bc1qsender1', 'bc1qsender2'],
);

expect(result.type).toBe('receive');
expect(result.from.map((movement) => movement.address)).toStrictEqual([
'bc1qsender1',
'bc1qsender2',
]);
expect(result.from[0]?.asset).toStrictEqual({
amount: '0',
fungible: true,
unit: 'BTC',
type: Caip19Asset.Bitcoin,
});
});

it('deduplicates and ignores empty senders for a receive', () => {
const account = createMockAccount(0);
const output = createMockOutput(5000);
const transaction = createMockTransaction('receive222', [output]);

jest.spyOn(account, 'isMine').mockReturnValue(true);
jest
.mocked(Address.from_script)
.mockImplementationOnce(
() => ({ toString: () => 'bc1qreceiveoutput' }) as unknown as Address,
);

const result = mapToTransaction(
account,
{
tx: transaction,
txid: transaction.compute_txid(),
chain_position: { anchor: undefined, last_seen: undefined },
} as unknown as WalletTx,
['bc1qsender1', 'bc1qsender1', 'bc1qsender2'],
);

expect(result.from.map((movement) => movement.address)).toStrictEqual([
'bc1qsender1',
'bc1qsender2',
]);
});

it('leaves from empty for a receive when no senders were resolved', () => {
const account = createMockAccount(0);
const output = createMockOutput(5000);
const transaction = createMockTransaction('receive333', [output]);

jest.spyOn(account, 'isMine').mockReturnValue(true);
jest
.mocked(Address.from_script)
.mockImplementationOnce(
() => ({ toString: () => 'bc1qreceiveoutput' }) as unknown as Address,
);

const result = mapToTransaction(account, {
tx: transaction,
txid: transaction.compute_txid(),
chain_position: { anchor: undefined, last_seen: undefined },
} as unknown as WalletTx);

expect(result.from).toStrictEqual([]);
});
});
22 changes: 21 additions & 1 deletion packages/bitcoin-wallet-snap/src/handlers/mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,15 @@ export function mapToTransactionFees(
*
* @param account - The account account.
* @param walletTx - The Bitcoin transaction managed by this account.
* @param senders - Funding addresses of the transaction, resolved by the chain
* indexer. Only used for receives: Bitcoin inputs reference a previous outpoint
* rather than an address, so the counterparty cannot be derived locally.
* @returns The Keyring transaction.
*/
export function mapToTransaction(
account: BitcoinAccount,
walletTx: WalletTx,
senders: string[] = [],
): KeyringTransaction {
const { tx, chain_position: chainPosition, txid } = walletTx;
const { network } = account;
Expand Down Expand Up @@ -188,7 +192,9 @@ export function mapToTransaction(
// - from: empty as irrelevant because we might be sending from multiple addresses. Sufficient to say "Sent from Bitcoin Account".
// If it's a Receive transaction:
// - to: all the outputs spending to addresses we own.
// - from: empty as irrevelant because we might have hundreds of inputs in a tx. Point to explorer for details.
// - from: the addresses that funded the transaction, so the counterparty can
// be displayed instead of "Unavailable"/blank. Sourced from the chain
// indexer (`senders`), since inputs only reference a previous outpoint.
if (isSend) {
for (const txout of tx.output) {
// Only the change output is filtered out. Outputs to an address we own
Expand All @@ -209,6 +215,20 @@ export function mapToTransaction(
}
}
}

transaction.from = [...new Set(senders)].map((address) => ({
address,
// Bitcoin has a single fee paid by the sender; the received amount is
// already surfaced through `to`. The counterparty only needs its
// address, so a zero amount keeps the movement shape valid without
// implying the sender spent nothing.
asset: {
amount: '0',
fungible: true,
unit: networkToCurrencyUnit[network],
type: networkToCaip19[network],
},
}));
}

return transaction;
Expand Down
Loading
Loading