Merge branch 'renovate/all-minor-patch' into 'main'
[ProtonMail-WebClient.git] / packages / wallet / utils / transactions.ts
blobb8400c7ef646d74a9c00d5c360c35d4ba6b3236d
1 import type { WasmApiClients, WasmApiWalletTransaction, WasmTransactionDetails } from '@proton/andromeda';
2 import type { PrivateKeyReference } from '@proton/crypto';
3 import { SECOND } from '@proton/shared/lib/constants';
4 import { uint8ArrayToBase64String } from '@proton/shared/lib/helpers/encoding';
5 import type { DecryptedKey, SimpleMap } from '@proton/shared/lib/interfaces';
6 import noop from '@proton/utils/noop';
8 import type {
9     AccountIdByDerivationPathAndWalletId,
10     DecryptedTransactionData,
11     NetworkTransactionByHashedTxId,
12     SenderObject,
13 } from '../types';
14 import { removeMasterPrefix } from './account';
15 import { decryptTextData, decryptWalletData, encryptPgp, hmac } from './crypto';
17 const parsedRecipientList = (toList: string | null): SimpleMap<string> => {
18     try {
19         const parsed = toList ? JSON.parse(toList) : {};
21         // TODO: check with API why some toList are arrays
22         return Array.isArray(parsed) ? parsed[0] : parsed;
23     } catch {
24         return {};
25     }
28 /**
29  * BitcoinViaEmail API sets Sender as string, but in ExternalSend / ExternalReceive, sender is an object
30  */
31 const parseSender = (sender: string): string | SenderObject => {
32     try {
33         const parsed: SenderObject = JSON.parse(sender);
34         return parsed;
35     } catch {
36         return sender;
37     }
40 /**
41  * Decrypt transaction data. If addressKeys is not provided, we won't try to decrypt Body, Sender and ToList.
42  *
43  * Additionally, TransactionID decryption might fail if Tx was created by a third party (using address keys)
44  */
45 export const decryptTransactionData = async (
46     apiTransaction: WasmApiWalletTransaction,
47     walletKey: CryptoKey,
48     userPrivateKeys?: PrivateKeyReference[],
49     addressKeys?: PrivateKeyReference[]
50 ): Promise<DecryptedTransactionData> => {
51     const keys = [...(userPrivateKeys ? userPrivateKeys : []), ...(addressKeys ?? [])];
53     const [decryptedLabel = ''] = await decryptWalletData([apiTransaction.Label], walletKey).catch(() => []);
55     const { data: TransactionID } = await decryptTextData(apiTransaction.TransactionID, keys);
56     // Sender is encrypted with addressKey in BitcoinViaEmail but with userKey when manually set (unknown sender)
57     const Sender = apiTransaction.Sender && (await decryptTextData(apiTransaction.Sender, keys)).data;
58     const parsedSender = Sender && parseSender(Sender);
60     const apiTransactionB = {
61         ...apiTransaction,
62         Label: decryptedLabel,
63         TransactionID,
64         Sender: parsedSender,
65         ToList: {},
66     };
68     if (!addressKeys) {
69         return apiTransactionB;
70     }
72     const { data: Body } = apiTransaction.Body
73         ? await decryptTextData(apiTransaction.Body, addressKeys)
74         : { data: null };
75     const SerialisedToList = apiTransaction.ToList && (await decryptTextData(apiTransaction.ToList, addressKeys)).data;
77     const ToList = parsedRecipientList(SerialisedToList);
79     return {
80         ...apiTransactionB,
81         Body,
82         ToList,
83     };
86 export const buildNetworkTransactionByHashedTxId = async (
87     transactions: WasmTransactionDetails[],
88     hmacKey: CryptoKey
89 ): Promise<NetworkTransactionByHashedTxId> => {
90     return transactions.reduce((prevPromise, transaction) => {
91         return prevPromise.then(async (acc) => {
92             try {
93                 const hashedTxIdBuffer = await hmac(hmacKey, transaction.txid);
94                 const key = uint8ArrayToBase64String(new Uint8Array(hashedTxIdBuffer));
96                 return {
97                     ...acc,
98                     [key]: {
99                         HashedTransactionID: key,
100                         ...transaction,
101                     },
102                 };
103             } catch {
104                 return acc;
105             }
106         });
107     }, Promise.resolve({}));
111  * Fetches transactions from the API, decrypts the transaction data, and returns the decrypted transactions.
113  */
114 export const fetchApiTransactions = async ({
115     api,
116     hashedTxids,
117     walletId,
118     walletKey,
119     userPrivateKeys,
120     addressesPrivateKeys,
121 }: {
122     api: WasmApiClients;
123     hashedTxids: string[] | undefined;
124     walletId: string;
125     walletKey: CryptoKey;
126     userPrivateKeys: PrivateKeyReference[];
127     addressesPrivateKeys: PrivateKeyReference[];
128 }) => {
129     const transactionsApiData = await api.wallet
130         .getWalletTransactions(walletId, undefined, hashedTxids)
131         .then((data) => data[0]);
133     const fetched: DecryptedTransactionData[] = [];
135     // populate txData with api data
136     for (const { Data: transactionApiData } of transactionsApiData) {
137         const { HashedTransactionID } = transactionApiData;
139         if (HashedTransactionID) {
140             const decryptedTransactionData = await decryptTransactionData(
141                 transactionApiData,
142                 walletKey,
143                 userPrivateKeys,
144                 addressesPrivateKeys
145             );
147             fetched.push(decryptedTransactionData);
148         }
149     }
151     return fetched;
155  * Encrypts the transaction data, creates new transactions in the API and then returns
156  * the created transactions.
157  */
158 export const createApiTransactions = async ({
159     api,
160     walletId,
161     walletKey,
162     accountIDByDerivationPathByWalletID,
163     transactionsWithoutApiData,
164     userKeys,
165     checkShouldAbort,
166 }: {
167     api: WasmApiClients;
168     walletId: string;
169     walletKey: CryptoKey;
170     accountIDByDerivationPathByWalletID: AccountIdByDerivationPathAndWalletId;
171     transactionsWithoutApiData: (WasmTransactionDetails & { HashedTransactionID: string })[];
172     userKeys: DecryptedKey[];
173     checkShouldAbort: () => boolean;
174 }) => {
175     const created: DecryptedTransactionData[] = [];
176     const [primaryUserKeys] = userKeys;
178     for (const transaction of transactionsWithoutApiData) {
179         if (checkShouldAbort()) {
180             break;
181         }
183         try {
184             const normalisedDerivationPath = removeMasterPrefix(transaction.account_derivation_path);
185             const accountId = accountIDByDerivationPathByWalletID[walletId]?.[normalisedDerivationPath];
187             if (!accountId) {
188                 continue;
189             }
191             const txid = await encryptPgp(transaction.txid, [primaryUserKeys.publicKey]);
193             // TODO: this can only occur on encryption error: we need to better handle that
194             if (!txid) {
195                 continue;
196             }
198             const { Data: createdTransaction } = await api.wallet.createWalletTransaction(walletId, accountId, {
199                 txid,
200                 hashed_txid: transaction.HashedTransactionID,
201                 transaction_time: transaction.time?.confirmation_time
202                     ? transaction.time?.confirmation_time.toString()
203                     : Math.floor(Date.now() / SECOND).toString(),
204                 label: null,
205                 exchange_rate_id: null,
206             });
208             const decryptedTransactionData = await decryptTransactionData(
209                 createdTransaction,
210                 walletKey,
211                 userKeys.map((k) => k.privateKey)
212             );
214             created.push(decryptedTransactionData);
215         } catch (error) {
216             console.error('Could not create missing tx data', error);
217         }
218     }
220     return created;
223 const getWalletTransactionsToHash = async (api: WasmApiClients, walletId: string) => {
224     try {
225         const walletTransactionsToHash = await api.wallet.getWalletTransactionsToHash(walletId);
226         return walletTransactionsToHash[0];
227     } catch {
228         return [];
229     }
233  * This function hashes API transactions by first decrypting the transaction data,
234  * then hashing the transaction ID, and finally updating the transaction with the hashed ID.
235  */
236 export const hashApiTransactions = async ({
237     api,
238     walletId,
239     walletKey,
240     hmacKey,
241     userPrivateKeys,
242     addressesPrivateKeys,
243     checkShouldAbort,
244 }: {
245     api: WasmApiClients;
246     walletId: string;
247     walletKey: CryptoKey;
248     hmacKey: CryptoKey;
249     userPrivateKeys: PrivateKeyReference[];
250     addressesPrivateKeys: PrivateKeyReference[];
251     checkShouldAbort: () => boolean;
252 }) => {
253     const hashed: DecryptedTransactionData[] = [];
255     // TODO: check pagination
256     const walletTransactionsToHash = await getWalletTransactionsToHash(api, walletId);
258     for (const walletTransactionToHash of walletTransactionsToHash) {
259         if (checkShouldAbort()) {
260             break;
261         }
263         try {
264             // Decrypt txid
265             const decryptedTransactionData = await decryptTransactionData(
266                 walletTransactionToHash.Data,
267                 walletKey,
268                 userPrivateKeys,
269                 addressesPrivateKeys
270             );
272             // TODO: this can only occur if decryption fails. We need to better handle that
273             if (!decryptedTransactionData.TransactionID || !walletTransactionToHash.Data.WalletAccountID) {
274                 continue;
275             }
277             // Then hash it
278             const hashedTxIdBuffer = await hmac(hmacKey, decryptedTransactionData.TransactionID);
279             const hashedTransactionID = uint8ArrayToBase64String(new Uint8Array(hashedTxIdBuffer));
281             await api.wallet
282                 .updateWalletTransactionHashedTxId(
283                     walletId,
284                     walletTransactionToHash.Data.WalletAccountID,
285                     walletTransactionToHash.Data.ID,
286                     hashedTransactionID
287                 )
288                 .catch(noop);
290             hashed.push({
291                 ...decryptedTransactionData,
292                 HashedTransactionID: hashedTransactionID,
293             });
294         } catch (e) {
295             // TODO: do something to avoid creating wallet transaction when error occurs here
296             console.error('An error occurred during transaction decryption, we will create a new transaction', e);
297         }
298     }
300     return hashed;