import type { Account } from "@ledgerhq/types-live"; import { DisconnectedDeviceDuringOperation, TransportStatusError, WrongDeviceForAccountPayout, WrongDeviceForAccountRefund, } from "@ledgerhq/errors"; import { createExchange, ExchangeTypes, getExchangeErrorMessage, PayloadSignatureComputedFormat, } from "@ledgerhq/hw-app-exchange"; import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName"; import { log } from "@ledgerhq/logs"; import BigNumber from "bignumber.js"; import invariant from "invariant"; import { Observable } from "rxjs"; import { secp256k1 } from "@noble/curves/secp256k1"; import { getCurrencyExchangeConfig } from "../"; import { getAccountCurrency, getMainAccount } from "../../account"; import { getAccountBridge } from "../../bridge"; import { TransactionRefusedOnDevice } from "../../errors"; import { handleHederaTrustedFlow } from "../../families/hedera/exchange"; import { withDevicePromise } from "../../hw/deviceAccess"; import { delay } from "../../promise"; import { CompleteExchangeStep, convertTransportError } from "../error"; import type { CompleteExchangeInputSwap, CompleteExchangeRequestEvent } from "../platform/types"; import { convertToAppExchangePartnerKey, getSwapProvider } from "../providers"; import { CEXProviderConfig } from "../providers/swap"; import { isAddressSanctioned } from "@ledgerhq/ledger-wallet-framework/sanction/index"; import { AddressesSanctionedError } from "@ledgerhq/ledger-wallet-framework/sanction/errors"; import { getCryptoCurrencyById } from "../../currencies"; const COMPLETE_EXCHANGE_LOG = "SWAP-CompleteExchange"; const LIFI_GAS_LIMIT_BUFFER_MULTIPLIER = 1.3; const completeExchange = ( input: CompleteExchangeInputSwap, ): Observable => { let { transaction } = input; // TODO build a tx from the data const { deviceId, deviceModelId, exchange, provider, binaryPayload, signature, rateType, exchangeType, } = input; const { fromAccount, fromParentAccount } = exchange; const { toAccount, toParentAccount } = exchange; return new Observable(o => { let unsubscribed = false; let ignoreTransportError = false; let currentStep: CompleteExchangeStep = "INIT"; const confirmExchange = async () => { if (deviceId === undefined) { throw new DisconnectedDeviceDuringOperation(); } await withDevicePromise(deviceId, async transport => { const providerConfig = await getSwapProvider(provider); if (providerConfig.useInExchangeApp === false) { throw new Error(`Unsupported provider type ${providerConfig.type}`); } const exchange = createExchange(transport, exchangeType, rateType, providerConfig.version); const refundAccount = getMainAccount(fromAccount, fromParentAccount); const payoutAccount = getMainAccount(toAccount, toParentAccount); const accountBridge = getAccountBridge(refundAccount); const payoutAccountBridge = getAccountBridge(payoutAccount); const mainPayoutCurrency = getAccountCurrency(payoutAccount); const payoutCurrency = getAccountCurrency(toAccount); const refundCurrency = getAccountCurrency(fromAccount); const mainRefundCurrency = getAccountCurrency(refundAccount); const sanctionedAddresses: string[] = []; for (const acc of [refundAccount, payoutAccount]) { const isSanctioned = await isAddressSanctioned(acc.currency, acc.freshAddress); if (isSanctioned) sanctionedAddresses.push(acc.freshAddress); } if (sanctionedAddresses.length > 0) { throw new AddressesSanctionedError("AddressesSanctionedError", { addresses: sanctionedAddresses, }); } if (mainPayoutCurrency.type !== "CryptoCurrency") throw new Error("This should be a cryptocurrency"); if (mainRefundCurrency.type !== "CryptoCurrency") throw new Error("This should be a cryptocurrency"); const isDex = ["lifi", "thorswap"].includes(provider.toLowerCase()); // Thorswap/LiFi ERC20 token exception hack: // - We remove subAccountId to prevent EVM calldata swap during prepareTransaction. // - Set amount to 0 to ensure correct handling of the transaction // (this is adjusted during prepareTransaction before signing the actual EVM transaction for tokens but we skip it). // - Since it's an ERC20 token transaction (not ETH), amount is set to 0 ETH // because no ETH is being sent, only tokens. // - This workaround can't be applied earlier in the flow as the amount is used for display purposes and checks. // We must set the amount to 0 at this stage to avoid issues during the transaction. // - This ensures proper handling of Thorswap/LiFi ERC20-specific transactions. if (isDex && transaction.subAccountId && transaction.family === "evm") { const transactionFixed = { ...transaction, subAccountId: undefined, amount: BigNumber(0), }; transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed); } else { transaction = await accountBridge.prepareTransaction(refundAccount, transaction); } if (transaction.family === "bitcoin") { const transactionFixed = { ...transaction, rbf: true, }; transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed); } if (unsubscribed) return; // The result of estimateGas is quite accurate in calculating how much gas the user will have to pay // for after the execution of the transaction, // but it does not represent how much gas the blockchain requires to successfully evaluate a transaction. // This is due to gas refunds of storage slots that get set and cleaned in the same transaction, // gas buffers in some implementations and other things. // Please always add a buffer on top of that value, LiFi recommends 25-30%. if ( isDex && transaction.family === "evm" && transaction.gasLimit && BigNumber.isBigNumber(transaction.gasLimit) ) { const gasLimit = transaction.gasLimit .times(LIFI_GAS_LIMIT_BUFFER_MULTIPLIER) .integerValue(BigNumber.ROUND_UP); const transactionFixed = { ...transaction, fees: undefined, // to be recalculated customGasLimit: gasLimit, }; transaction = await accountBridge.prepareTransaction(refundAccount, transactionFixed); } const { errors, estimatedFees } = await accountBridge.getTransactionStatus( refundAccount, transaction, ); if (unsubscribed) return; const errorsKeys = Object.keys(errors); if (errorsKeys.length > 0) throw errors[errorsKeys[0]]; // throw the first error currentStep = "SET_PARTNER_KEY"; await exchange.setPartnerKey( convertToAppExchangePartnerKey(providerConfig as CEXProviderConfig), ); if (unsubscribed) return; currentStep = "CHECK_PARTNER"; await exchange.checkPartner((providerConfig as CEXProviderConfig).signature); if (unsubscribed) return; currentStep = "PROCESS_TRANSACTION"; const { payload, format }: { payload: Buffer; format: PayloadSignatureComputedFormat } = exchange.transactionType === ExchangeTypes.SwapNg ? { payload: Buffer.from("." + binaryPayload), format: "jws" } : { payload: Buffer.from(binaryPayload, "hex"), format: "raw" }; await exchange.processTransaction(payload, estimatedFees, format); if (unsubscribed) return; const goodSign = convertSignature(signature, exchange.transactionType); currentStep = "CHECK_TRANSACTION_SIGNATURE"; await exchange.checkTransactionSignature(goodSign); if (unsubscribed) return; // Hedera swap payload is filled with user account address, // but the device app requires the related public key for verification. // Since this key is stored on-chain, we use the TrustedService // to fetch a signed descriptor linking the address to its public key. const hederaCurrency = getCryptoCurrencyById("hedera"); let hederaAccount: Account | null = null; if (payoutAccount.currency.family === hederaCurrency.family) { hederaAccount = payoutAccount; } else if (refundAccount.currency.family === hederaCurrency.family) { hederaAccount = refundAccount; } if (hederaAccount) { invariant(deviceModelId, "hedera: deviceModelId is not available"); await handleHederaTrustedFlow({ exchange, hederaAccount, deviceModelId }); if (unsubscribed) return; } const payoutAddressParameters = payoutAccountBridge.getSerializedAddressParameters( payoutAccount, mainPayoutCurrency.id, ); if (unsubscribed) return; if (!payoutAddressParameters) { throw new Error(`Family not supported: ${mainPayoutCurrency.family}`); } //-- CHECK_PAYOUT_ADDRESS const { config: payoutAddressConfig, signature: payoutAddressConfigSignature } = await getCurrencyExchangeConfig(payoutCurrency); try { currentStep = "CHECK_PAYOUT_ADDRESS"; await exchange.validatePayoutOrAsset( payoutAddressConfig, payoutAddressConfigSignature, payoutAddressParameters, ); } catch (e) { if (e instanceof TransportStatusError && e.statusCode === 0x6a83) { throw new WrongDeviceForAccountPayout( getExchangeErrorMessage(e.statusCode, currentStep).errorMessage, { accountName: getDefaultAccountName(payoutAccount), }, ); } throw convertTransportError(currentStep, e); } o.next({ type: "complete-exchange-requested", estimatedFees: estimatedFees.toString(), }); // Swap specific checks to confirm the refund address is correct. if (unsubscribed) return; const refundAddressParameters = accountBridge.getSerializedAddressParameters( refundAccount, mainRefundCurrency.id, ); if (unsubscribed) return; if (!refundAddressParameters) { throw new Error(`Family not supported: ${mainRefundCurrency.family}`); } const { config: refundAddressConfig, signature: refundAddressConfigSignature } = await getCurrencyExchangeConfig(refundCurrency); if (unsubscribed) return; try { currentStep = "CHECK_REFUND_ADDRESS"; await exchange.checkRefundAddress( refundAddressConfig, refundAddressConfigSignature, refundAddressParameters, ); log(COMPLETE_EXCHANGE_LOG, "checkrefund address"); } catch (e) { if (e instanceof TransportStatusError && e.statusCode === 0x6a83) { log(COMPLETE_EXCHANGE_LOG, "transport error"); throw new WrongDeviceForAccountRefund( getExchangeErrorMessage(e.statusCode, currentStep).errorMessage, { accountName: getDefaultAccountName(refundAccount), }, ); } throw convertTransportError(currentStep, e); } if (unsubscribed) return; ignoreTransportError = true; currentStep = "SIGN_COIN_TRANSACTION"; await exchange.signCoinTransaction(); }).catch(e => { if (ignoreTransportError) return; // During signature delegation, Exchange does not remap refusal errors from the coin app/OS. // 0x6a84: user refused the proposal for an owned destination address. // 0x5501: BOLOS/OS-level refusal (not an Exchange app error code). if ( e instanceof TransportStatusError && (e.statusCode === 0x6a84 || e.statusCode === 0x5501) ) { throw new TransactionRefusedOnDevice(); } throw convertTransportError(currentStep, e); }); await delay(3000); if (unsubscribed) return; o.next({ type: "complete-exchange-result", completeExchangeResult: transaction, }); }; confirmExchange().then( () => { o.complete(); unsubscribed = true; }, e => { o.next({ type: "complete-exchange-error", error: e, }); o.complete(); unsubscribed = true; }, ); return () => { unsubscribed = true; }; }); }; function convertSignature(signature: string, exchangeType: ExchangeTypes): Buffer { return exchangeType === ExchangeTypes.SwapNg ? base64UrlDecode(signature) : (() => { const sig = secp256k1.Signature.fromCompact(Buffer.from(signature, "hex")); return Buffer.from(sig.toDERRawBytes()); })(); } function base64UrlDecode(base64Url: string): Buffer { // React Native Hermes engine does not support Buffer.from(signature, "base64url") const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); return Buffer.from(base64, "base64"); } export default completeExchange;