Merge branch 'format-suggestion-summary' into 'main'
[ProtonMail-WebClient.git] / packages / wallet / utils / transactions.ts
blobd18792d908bae9a022e4ccd5eb8de1de1e9cc8bd
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  * Additionnally, 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 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));
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 Body = apiTransaction.Body && (await decryptTextData(apiTransaction.Body, addressKeys));
73     const SerialisedToList = apiTransaction.ToList && (await decryptTextData(apiTransaction.ToList, addressKeys));
75     const ToList = parsedRecipientList(SerialisedToList);
77     return {
78         ...apiTransactionB,
79         Body,
80         ToList,
81     };
84 export const buildNetworkTransactionByHashedTxId = async (
85     transactions: WasmTransactionDetails[],
86     hmacKey: CryptoKey
87 ): Promise<NetworkTransactionByHashedTxId> => {
88     return transactions.reduce((prevPromise, transaction) => {
89         return prevPromise.then(async (acc) => {
90             try {
91                 const hashedTxIdBuffer = await hmac(hmacKey, transaction.txid);
92                 const key = uint8ArrayToBase64String(new Uint8Array(hashedTxIdBuffer));
94                 return {
95                     ...acc,
96                     [key]: {
97                         HashedTransactionID: key,
98                         ...transaction,
99                     },
100                 };
101             } catch {
102                 return acc;
103             }
104         });
105     }, Promise.resolve({}));
109  * Fetches transactions from the API, decrypts the transaction data, and returns the decrypted transactions.
111  */
112 export const fetchApiTransactions = async ({
113     api,
114     hashedTxids,
115     walletId,
116     walletKey,
117     userPrivateKeys,
118     addressesPrivateKeys,
119 }: {
120     api: WasmApiClients;
121     hashedTxids: string[] | undefined;
122     walletId: string;
123     walletKey: CryptoKey;
124     userPrivateKeys: PrivateKeyReference[];
125     addressesPrivateKeys: PrivateKeyReference[];
126 }) => {
127     const transactionsApiData = await api.wallet
128         .getWalletTransactions(walletId, undefined, hashedTxids)
129         .then((data) => data[0]);
131     const fetched: DecryptedTransactionData[] = [];
133     // populate txData with api data
134     for (const { Data: transactionApiData } of transactionsApiData) {
135         const { HashedTransactionID } = transactionApiData;
137         if (HashedTransactionID) {
138             const decryptedTransactionData = await decryptTransactionData(
139                 transactionApiData,
140                 walletKey,
141                 userPrivateKeys,
142                 addressesPrivateKeys
143             );
145             fetched.push(decryptedTransactionData);
146         }
147     }
149     return fetched;
153  * Encrypts the transaction data, creates new transactions in the API and then returns
154  * the created transactions.
155  */
156 export const createApiTransactions = async ({
157     api,
158     walletId,
159     walletKey,
160     accountIDByDerivationPathByWalletID,
161     transactionsWithoutApiData,
162     userKeys,
163     checkShouldAbort,
164 }: {
165     api: WasmApiClients;
166     walletId: string;
167     walletKey: CryptoKey;
168     accountIDByDerivationPathByWalletID: AccountIdByDerivationPathAndWalletId;
169     transactionsWithoutApiData: (WasmTransactionDetails & { HashedTransactionID: string })[];
170     userKeys: DecryptedKey[];
171     checkShouldAbort: () => boolean;
172 }) => {
173     const created: DecryptedTransactionData[] = [];
174     const [primaryUserKeys] = userKeys;
176     for (const transaction of transactionsWithoutApiData) {
177         if (checkShouldAbort()) {
178             break;
179         }
181         try {
182             const normalisedDerivationPath = removeMasterPrefix(transaction.account_derivation_path);
183             const accountId = accountIDByDerivationPathByWalletID[walletId]?.[normalisedDerivationPath];
185             if (!accountId) {
186                 continue;
187             }
189             const txid = await encryptPgp(transaction.txid, [primaryUserKeys.publicKey]);
191             // TODO: this can only occur on encryption error: we need to better handle that
192             if (!txid) {
193                 continue;
194             }
196             const { Data: createdTransaction } = await api.wallet.createWalletTransaction(walletId, accountId, {
197                 txid,
198                 hashed_txid: transaction.HashedTransactionID,
199                 transaction_time: transaction.time?.confirmation_time
200                     ? transaction.time?.confirmation_time.toString()
201                     : Math.floor(Date.now() / SECOND).toString(),
202                 label: null,
203                 exchange_rate_id: null,
204             });
206             const decryptedTransactionData = await decryptTransactionData(
207                 createdTransaction,
208                 walletKey,
209                 userKeys.map((k) => k.privateKey)
210             );
212             created.push(decryptedTransactionData);
213         } catch (error) {
214             console.error('Could not create missing tx data', error);
215         }
216     }
218     return created;
221 const getWalletTransactionsToHash = async (api: WasmApiClients, walletId: string) => {
222     try {
223         const walletTransactionsToHash = await api.wallet.getWalletTransactionsToHash(walletId);
224         return walletTransactionsToHash[0];
225     } catch {
226         return [];
227     }
231  * This function hashes API transactions by first decrypting the transaction data,
232  * then hashing the transaction ID, and finally updating the transaction with the hashed ID.
233  */
234 export const hashApiTransactions = async ({
235     api,
236     walletId,
237     walletKey,
238     hmacKey,
239     userPrivateKeys,
240     addressesPrivateKeys,
241     checkShouldAbort,
242 }: {
243     api: WasmApiClients;
244     walletId: string;
245     walletKey: CryptoKey;
246     hmacKey: CryptoKey;
247     userPrivateKeys: PrivateKeyReference[];
248     addressesPrivateKeys: PrivateKeyReference[];
249     checkShouldAbort: () => boolean;
250 }) => {
251     const hashed: DecryptedTransactionData[] = [];
253     // TODO: check pagination
254     const walletTransactionsToHash = await getWalletTransactionsToHash(api, walletId);
256     for (const walletTransactionToHash of walletTransactionsToHash) {
257         if (checkShouldAbort()) {
258             break;
259         }
261         try {
262             // Decrypt txid
263             const decryptedTransactionData = await decryptTransactionData(
264                 walletTransactionToHash.Data,
265                 walletKey,
266                 userPrivateKeys,
267                 addressesPrivateKeys
268             );
270             // TODO: this can only occur if decryption fails. We need to better handle that
271             if (!decryptedTransactionData.TransactionID || !walletTransactionToHash.Data.WalletAccountID) {
272                 continue;
273             }
275             // Then hash it
276             const hashedTxIdBuffer = await hmac(hmacKey, decryptedTransactionData.TransactionID);
277             const hashedTransactionID = uint8ArrayToBase64String(new Uint8Array(hashedTxIdBuffer));
279             await api.wallet
280                 .updateWalletTransactionHashedTxId(
281                     walletId,
282                     walletTransactionToHash.Data.WalletAccountID,
283                     walletTransactionToHash.Data.ID,
284                     hashedTransactionID
285                 )
286                 .catch(noop);
288             hashed.push({
289                 ...decryptedTransactionData,
290                 HashedTransactionID: hashedTransactionID,
291             });
292         } catch (e) {
293             // TODO: do something to avoid creating wallet transaction when error occurs here
294             console.error('An error occurred during transaction decryption, we will create a new transaction', e);
295         }
296     }
298     return hashed;